fix(client): trap browser Back to suppress SPA unload + state desync (#109) #117
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/109-back-button-trap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Adds a browser-Back trap to the client. The SPA uses no routing/history (probe confirmed: zero
pushState/replaceState/popstate/hashchangeusage insrc/), so pressing Back used to unload the page entirely. On mobile Safari that meant the page got bfcache-frozen on a stale screen (e.g. CELLMATE FOUND) with a dead socket, while the server saw the drop and re-queued the opponent — the desync the operator hit in the 2026-06-22 iPhone+desktop versus playtest (#109). Closes #109.The fix seeds one in-document history entry at boot and re-seeds it on every
popstate, so Back becomes an in-app no-op: it pops to a same-document entry (firingpopstate, not a cross-document unload), the handler immediately re-pins the tip, and the page / socket / state survive every press. Forward is inert too (there's never a forward entry to visit).Probe (AC1)
grep -rn "pushState|replaceState|popstate|hashchange|history\.|location.hash" src/→ empty. The client is a single canvas-rendered document with atitle|checkin|connectedscreen machine and no URL/history coupling. So Back navigates the browser's stack with the app none the wiser — exactly the issue's hypothesis, confirmed at source.Decision tree — mechanism: why a pushState-sentinel trap, not pure replaceState
The tracker leaned toward "replaceState every screen transition → browser-history never accumulates entries → Back does nothing." Pure
replaceStatedoes not actually fix this, and the reasoning is worth recording:replaceStaterewrites the current entry. But the app accumulates zero in-app entries to begin with — so there's nothing to "replace away." Back still pops to whatever preceded our single entry (an external page, or the tab's start) and unloads the SPA. Replacing the current entry changes nothing about that.pushState(a sentinel), plus apopstatelistener that re-pushes — so repeated Backs (the operator's back→forward→back sequence) keep re-pinning the tip and never reach the external predecessor.So this PR satisfies the issue's AC2(a) goal ("Back does nothing in-app") via a
popstatemechanism — the suppression is real, the literal API ispushState+popstaterather thanreplaceState. The single re-push inside the handler is the load-bearing line (it's what defends against the second Back), and it's exactly what the mutation experiment kills.Decision tree — UX: no-op suppression vs Back→leave
backToYard()(a graceful in-app "leave to yard", the #93 recovery destination). This honors a "Back = go back" mental model, but an accidental Back would then forfeit an active match — strictly worse for the reported scenario.When the alternative would be right instead: if the operator wants Back to deliberately mean "leave the match / return to the yard" (rather than be inert), flip the
popstatehandler fromre-push onlytore-push + backToYard(). The trap scaffolding (sentinel + listener) is identical either way — only the handler body changes — so this is a one-line pivot if review prefers it.Design calls
historyTrapHitscounter surfaced through the?navtest__navStateseam. The trap's effect is "the page didn't navigate," which is awkward to assert directly; the counter gives the harness a positive signal thatpopstatewas intercepted and the tip re-pinned, without needing a real cross-document unload (which Playwright's history model handles murkily).Acceptance criteria
src/(grep above).popstatere-push) — AC2(a).#109versus.spectest driveshistory.back()against a live mock match.Mutation experiment (discriminating)
Removed the
window.addEventListener('popstate', …)listener (kept the boot sentinel):#109test reds —historyTrapHitsnever increments (poll times out attoBe(1)); a real Back would escape the document.#92: full versus flowstays green — no other test touches the counter / browser history.Reverted precisely (re-edit, not
git checkout). Full suite after revert: 55 passed (was 54; +1 for #109),tsc --noEmitclean.What this PR does NOT do
connected.Verify
🤖 Generated with Claude Code
✅ APPROVED — browser Back-button trap (#109)
Reviewed at head
bcaa4af(on current mainc29326b, mergeable). The mechanism was the part to scrutinize, and it's correct.Mechanism verified
grepofsrc/forpushState/replaceState/popstate/hashchange/history.returns only the new trap — zero pre-existing history/routing usage. So the "Back unloads the SPA" diagnosis holds and there's no conflict.popstate, not a cross-document unload), the handler re-pins the tip, and the external predecessor is never reached. The re-push is the load-bearing line.pushStatetruncates forward history and re-adds one tip, so the stack holds steady at depth 2 (no unbounded growth across repeated Backs). Forward is inert (always at the tip). ✓Verification
npx tsc --noEmitclean; 55/55 green (CI=1fresh), new #109 row included.history.back()→ 1 →history.back()→ 2 (the second press is what proves the re-push defends, not just the boot sentinel), then asserts screen/phase unchanged (no unload/desync). Good call usinghistory.back()overpage.goBack()(the latter waits on a load event an in-document popstate never fires).Your two design questions
(1) Always-on vs
connected-scoped — keep always-on. For a full-screen canvas game it's the right call: installing once at boot avoids the arm/disarm lifecycle, which for history traps is genuinely bug-prone (stale sentinels, listener leaks, double-push across screen transitions). The only real cost is the referrer-trap — a user who arrived via a link can't Back out to the referrer from the title screen; they exit by closing the tab. For this game (direct-URL/bookmark entry, mobile-primary where Back-traps are an established idiom) that's acceptable. Decision-tree: scope toconnectedonly if the operator specifically wants Back-to-leave-the-site working on the title screen — the desync harm itself is during-match, so the scoping would buy only that title-screen referrer-exit, at the cost of the lifecycle complexity. Defensible as built.(2) No-op vs Back→backToYard — keep no-op. The repro is accidental Back during a live match; mapping Back→backToYard would convert a fat-finger into a match-leave (worse for exactly the reported scenario). The operator's suppression ratify is the right read. The alternative only coheres under a "Back = deliberately leave" mental model that contradicts the accidental-press framing. Agreed as built.
One honest note on the composition: (always-on + no-op) means Back is globally inert — maximally predictable, and the most defensible position for the reported harm, but worth the operator knowing it's "Back does nothing, anywhere." Easy to revisit later (the trap scaffolding is identical; only the handler body changes) if their mental model differs.
Clean, correct, well-scoped fix with two sound design calls. Closes #109. Merge-ready → Bosun.