fix: refuse a second signup popup while an open one is still undecided (TWO-40) - #482
fix: refuse a second signup popup while an open one is still undecided (TWO-40)#4824 commits merged into
Conversation
…d (TWO-40) Two sequential Sole Trader chip clicks stacked two hosted-signup popups: openingSignup only guards re-entry within one gesture. The new guard in launchSignup is popup-scoped — open watcher present AND outcome undecided (not adopted, or a re-signup pending) — so it refuses only genuine stacking, never a post-accept re-signup, a deferred applyPrefetch launch, a blocked-popup retry, the note link, or a launch with only a prefetch flight outstanding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🖌 Pre-commit success 🏆DetailsExit code: 0 Author ✍️@dgjlindsay |
…tate (TWO-40)
Review round 1 (by Claude) found two ways the guard's inputs go stale:
an accepted-then-closed re-signup spent both the ACCEPTED handler's and
the close poll's decrement against one increment, and a prefetch match
landing under a still-open first-time popup set soleTraderAdopted while
that popup was undecided. Watchers are now records {id, isReconfirming,
decided}: ACCEPTED pairs with the one undecided record, each decrement
belongs to exactly one owner, and the launch guard reads the records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stale poll window (TWO-40) Review round 2 (by Claude): the guard read only the record's decided flag, so for up to one 300ms poll cycle after the buyer closed the popup a chip click yielded nothing and the poll then reverted to business. The guard now also checks win.closed, and the poll's abandon-revert defers while another undecided popup (relaunched in that window) is open. Adds mutation-sensitive tests for the record filter, the signupConfirming disjunct, and the relaunch window; freshens the two stale counter comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
HOLD — do not merge. Adversarial review round 3 found two HIGH regressions traced to round 2's own fix (the Candidate direction (unverified, not applied): pair ACCEPTED with the live popup — Review rounds 1–3 by Claude. |
Review round 3 (by Claude) held this PR: round 2's `!win.closed` disjunct in the launch guard deliberately lets a relaunch open alongside a hand-closed record, which falsified the "at most one undecided record" invariant that the ACCEPTED handler's `find(!decided)` depended on. With two undecided records the forward scan returned the STALE hand-closed one ahead of the live popup that actually sent the message, so the wrong record was marked decided — leaving the live popup undecided and open, which refused the sanctioned post-accept re-signup for as long as the hosted flow left its window up, and billed the accepting popup's `soleTraderReconfirmingCount` decrement to a record that never owed one, stranding the count above zero and blocking every leave-sole-trader action. Pairing no longer searches by outcome. `findPopupWatcher` reads `event.source` — the browser names the window that posted, and a WindowProxy is reference-comparable across origins — so the popup identifies itself and the number of undecided records stops mattering. Its fallbacks, for a popup that closes in the same turn it posts and arrives with a null source, scan newest first, so a relaunch always wins over the stale record it opened over. The decrement is gated on the receipt that actually settles a popup, so a replayed ACCEPTED cannot spend a second decrement against one increment. Not the same class of fix as rounds 1-3: those each re-tuned a predicate over the record list and the next round found the new predicate wrong for some other popup. This removes the search, so there is no predicate left to get wrong. Round 1 of this review also found the mode-revert deferral asking the wrong question: `!other.decided` let a stale poll revert mode out from under a popup that was decided but still on screen after an accept resolved to no buyer, dropping the buyer's retry on the handler's own `mode !== "sole_trader"` gate. It now asks whether another popup is still ON SCREEN; a record whose window has closed settles the mode from its own poll. Adds nine tests, each verified mutation-sensitive against the specific predicate it pins: stale-record pairing, the refused post-accept re-signup, the stranded counter, the replayed ACCEPTED, both no-source fallbacks, the newest-first scan order, and the revert deferral in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3 hold resolved — pairing no longer searches by outcomeWhat was wrongRound 2's With two undecided records in the list, the handler's
Both were reproduced as failing tests before any fix — 5 of the 6 new pairing tests were red against the held tip. What changedThe search is gone, not re-tuned. The decrement is now gated on the receipt that actually settles a popup, so a replayed ACCEPTED cannot spend a second decrement against one increment. An unmatched non-null Why this is not the same class of bug as rounds 1-3Rounds 1, 2 and 3 each re-tuned a predicate over the record list — The false invariant in the Review rounds run
No oscillation: rounds 2 and 3 each found nothing behavioural introduced by the round before. TestsNine new tests, each verified mutation-sensitive against the specific predicate it pins (reverted the predicate, confirmed the test goes red, restored): stale-record pairing, the refused post-accept re-signup, the stranded counter, the replayed ACCEPTED, both no-source fallbacks, the newest-first scan order, and the revert deferral in both directions. Full suite: 644 passed, 18 suites, 0 failures (635 before this change). Prettier clean under the pinned 3.1.0. Not mergingFlagging for human sign-off rather than self-merging, given three prior oscillations on this guard. The specific thing worth a human eye: by Claude |
Follow-up to #479: two sequential Sole Trader chip clicks stacked two hosted-signup popups —
openingSignuponly guards re-entry within one gesture, and the no-email branch #479 added inherits the same pre-existing gap as the resolved-no-match branch.The guard in
launchSignupis popup-scoped: refuse only while an open popup's outcome is undecided (a close watcher is live AND nothing adopted, or a re-signup pending). It never refuses a post-accept re-signup, a deferredapplyPrefetchlaunch, a blocked-popup retry (a blocked popup creates no watcher), the note link, or a launch with only a prefetch flight outstanding — the two previously rejected predicates (bare watcher count; flight-inclusiveisDeciding()) each broke one of those, and all five are now pinned by tests.🤖 Generated with Claude Code