fix(client): seed a depth buffer for the Back-trap (rapid double-click escape) #152
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/144-history-trap-depth"
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 & why
#109 seeded one in-document history sentinel + re-pushed it on every popstate. That holds for sequential Back presses (each pop is followed by a re-push before the next), but a rapid Back double/multi-click defeats it: the browser can commit the second traversal past the lone sentinel before the first popstate handler re-pushes, unloading the SPA to the pre-load entry. Operator v1.0.0 desktop repro: "a quick doubleclick / multiclick navigated back to the start page."
Probes-first finding: the filed hypothesis (and a sub-agent's first read) was that the synchronous re-push already closes the race. It does not — a re-push can only re-pin after a pop, so it can never close a synchronous-burst gap where K pops land before any handler runs. The live operator repro is ground-truth; the only robust defense is depth.
Fix (the issue's direction (a))
Seed a buffer of
HISTORY_TRAP_DEPTH(20) in-document entries at boot. A run of K rapid Backs pops K entries but lands on another in-document sentinel as long as K < depth, and the existing per-popstate re-push tops the buffer back up afterwards. 20 is far beyond any human multi-click yet trivially cheap (same-URLpushState, no navigation). The popstate re-push is unchanged.Decision tree: chose (a) depth buffer over (b) synchronous re-push because (b) is what #109 already does — the re-push is synchronous, and it still loses the race, so "verify timing" resolves to "timing can't be fixed in the handler." (c) close-as-wontfix is ruled out by the operator-ratified globally-inert disposition for #109 (Back must cost nothing).
Verification — mutation-proven closed loop (nav.spec)
history.go(-2)is the deterministic worst case — two pops in one atomic traversal, no re-push between, i.e. the rapid-burst the operator hit. With the buffer the page survives on/?…navtest; setHISTORY_TRAP_DEPTHto 1 →go(-2)pops past the lone sentinel to the pre-load entry → the SPA unloads (url leavesnavtest) → reds. Empirically confirmed (the mutation reds at the url-survival assertion), so the test genuinely reproduces the escape — not a trivial pass.versus.spec's #109 sequential-Back row stays green (per-pop re-push unchanged). 73 passed (full suite, CI=1),
tsc --noEmitclean.What this PR does NOT do
historyTrapHitstelemetry — only the boot seed depth.Closes #144.
🤖 Generated with Claude Code
✅ APPROVED — history-trap depth buffer (#144)
Reviewed at head
fd4cdad(on current mainbcf2eb5). Small, correct, and the test design is the highlight.The fix is the right shape
A single sentinel (#109) + reactive re-push can't close a synchronous-burst gap — the re-push only fires after a pop, so two pops landing before the handler runs escape the lone sentinel. The only robust defense is pre-seeded depth, and that's exactly what this does: 20 in-document sentinels at boot, so a K-rapid-Back burst lands on another sentinel while K<20, and the unchanged per-popstate re-push tops the buffer back up. The reasoning (reactive-repush vs proactive-depth) is sound, and 20 is cheap (same-URL pushState, no navigation) with huge margin over any human multi-click.
Corrected filed-read — operator repro as ground-truth
Good catch overriding the sub-agent's "sync re-push already closes it / no bug" conclusion. The live operator repro ("navigated back to the start page") is ground-truth, and the mechanism is exactly why the sub-agent was wrong: a re-push cannot close a gap that opens between two pops within one traversal. That's filed-rootcause-is-hypothesis applied to a "no-bug" verdict — the verdict was a hypothesis the repro falsified.
Test design — deterministic arbiter
history.go(-2)as the worst case is the smart move: two pops in one atomic traversal with no re-push between deterministically models the rapid-double-click adversary, so the test is timing-flake-free (no racing a synthetic double-click against the handler). I verified the discrimination directly — DEPTH 20→1 → the #144 test reds (go(-2) escapes, url leavesnavtest,__navStategone) and the #109 sequential-Back row stays green (sequential is the re-push's job, unchanged). So depth and re-push are independently load-bearing, each pinned by the test that exercises only it. The decision-tree ((a) depth / (b) sync-repush = what #109 already does / (c) wontfix-ruled-out) is the right framing.One nit (non-blocking)
The PR body says "63 green" — I count 73 green (full suite, CI=1); looks like a transposition typo. The suite is green either way, just worth correcting the number so the merge record is accurate. tsc clean, on current main.
Clean, well-reasoned, well-tested. Closes #144. Merge-ready → Bosun.