From 64ab85a671c7dfcae9e0333595c156fbd1c13ec4 Mon Sep 17 00:00:00 2001 From: Yaswanth Narvaneni Date: Sat, 17 Oct 2026 22:15:21 +0100 Subject: [PATCH 1/2] Keep one Back guard per open mobile layer Closing the top layer with the phone's Back re-pushed the shared guard while handling Back. Chrome skips history entries a page adds without a user gesture, so the next Back left the page instead of closing the next layer. Each open layer now has its own guard, pushed when it opens; Back consumes one per layer and never pushes, and layers closed on screen take theirs with them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfhSCw8PA5ALciQTEYHAAP --- web/src/components/ThemedSelect.test.ts | 15 ++++- web/src/systemBack.test.ts | 23 ++++++++ web/src/systemBack.ts | 73 ++++++++++++++++--------- 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/web/src/components/ThemedSelect.test.ts b/web/src/components/ThemedSelect.test.ts index bd09901a..20e73e64 100644 --- a/web/src/components/ThemedSelect.test.ts +++ b/web/src/components/ThemedSelect.test.ts @@ -144,10 +144,19 @@ async function registerDomTest() { } const back = async () => { await act(async () => { - // A browser Back lands on the unguarded base before firing popstate. - browser.history.replaceState(null, ""); + // A browser Back pops one guard (each open layer has its own) and + // lands on the entry below before firing popstate. + const state = browser.history.state as Record | null; + const depth = + typeof state?.roamgateBack === "number" + ? state.roamgateBack + : state && "roamgateBack" in state + ? 1 + : 0; + const below = depth > 1 ? { ...state, roamgateBack: depth - 1 } : null; + browser.history.replaceState(below, ""); browser.dispatchEvent( - new browser.PopStateEvent("popstate", { state: null }), + new browser.PopStateEvent("popstate", { state: below }), ); }); await act(async () => browser.happyDOM.whenAsyncComplete()); diff --git a/web/src/systemBack.test.ts b/web/src/systemBack.test.ts index ca205995..5632f32b 100644 --- a/web/src/systemBack.test.ts +++ b/web/src/systemBack.test.ts @@ -203,6 +203,29 @@ async function registerDomTests() { expect(at()).toBe("/a"); }); + test("Back pushes no entry, so a browser cannot skip the page on the next Back", async () => { + // Browsers skip entries a page pushes without a user gesture; each layer's + // guard is pushed when it opens, never while handling Back. + await start(); + await set(["composer"]); + await set(["composer", "sheet"]); + const pushState = history.pushState; + let pushes = 0; + history.pushState = (...args: Parameters) => { + pushes++; + pushState(...args); + }; + await userBack(); + expect(closed).toEqual(["sheet"]); + await userBack(); + expect(closed).toEqual(["sheet", "composer"]); + expect([at(), guarded()]).toEqual(["/b", false]); + history.pushState = pushState; + expect(pushes).toBe(0); + await userBack(); + expect(at()).toBe("/a"); + }); + test("the higher level closes first when layers register together", async () => { await start(); await set(["sheet", "composer"]); diff --git a/web/src/systemBack.ts b/web/src/systemBack.ts index 553321de..ecf12e6d 100644 --- a/web/src/systemBack.ts +++ b/web/src/systemBack.ts @@ -13,11 +13,14 @@ type Layer = { level: number; order: number; close: () => void }; /** * Lets the phone's Back gesture or button close an open mobile layer before - * it leaves the page. While any layer is open, the current history entry is - * one guard entry at the current URL. Back pops it and closes only the top - * layer; the guard returns while layers remain. When the last layer closes - * on screen, the guard is removed again, so the next Back leaves the page in - * one press. + * it leaves the page. Each open layer has one guard entry at the current URL, + * pushed when the layer opens. Back pops one guard and closes only the top + * layer; the remaining layers keep their own guards, so handling Back never + * pushes an entry. Browsers skip entries a page pushes without a user gesture + * (Chrome's back-button intervention), so a guard re-pushed while handling + * Back would make the next Back leave the page instead of closing the next + * layer. When layers close on screen, their guards are removed again, so the + * next Back leaves the page in one press. */ export function useSystemBack( open: boolean, @@ -63,6 +66,13 @@ function isGuard(state: unknown) { return typeof state === "object" && state !== null && GUARD in state; } +/** Guard entries above the page's own entry: one per open layer. */ +function guardDepth(state: unknown) { + if (!isGuard(state)) return 0; + const depth = (state as Record)[GUARD]; + return typeof depth === "number" && depth > 0 ? depth : 1; +} + function baseId(state: unknown): string | undefined { if (typeof state !== "object" || state === null || !(BASE in state)) return; const value = (state as Record)[BASE]; @@ -80,19 +90,24 @@ function scheduleReconcile() { function reconcile() { reconcileScheduled = false; if (backPending || leaving) return; - const guarded = isGuard(window.history.state); + const depth = guardDepth(window.history.state); if (layers.size > 0) forwardBase = undefined; - if (layers.size > 0 && !guarded) { + if (layers.size > depth) { const state: unknown = window.history.state; - // Pair the entries so an obsolete guard can be crossed in either direction. - const base = { - ...(typeof state === "object" && state ? state : {}), - [BASE]: `${Date.now()}:${++guards}`, - }; - window.history.replaceState(base, ""); - window.history.pushState({ ...base, [GUARD]: true }, ""); + let base: Record; + if (depth === 0) { + // Pair the entries so an obsolete guard can be crossed in either direction. + base = { + ...(typeof state === "object" && state ? state : {}), + [BASE]: `${Date.now()}:${++guards}`, + }; + window.history.replaceState(base, ""); + } else base = { ...(state as Record) }; + for (let next = depth + 1; next <= layers.size; next += 1) + window.history.pushState({ ...base, [GUARD]: next }, ""); previousState = window.history.state; - } else if (layers.size === 0 && guarded && forwardBase === undefined) { + } else if (layers.size < depth && forwardBase === undefined) { + // Layers closed on screen take their guards with them, one step at a time. backPending = true; window.history.back(); } @@ -147,21 +162,25 @@ if (typeof window !== "undefined") { window.history.forward(); return; } - if (isGuard(event.state) || layers.size === 0) { + const depth = guardDepth(event.state); + if (layers.size <= depth) { scheduleReconcile(); return; } - let top: Layer | undefined; - for (const layer of layers) - if ( - !top || - layer.level > top.level || - (layer.level === top.level && layer.order > top.order) - ) - top = layer; - // The user's Back already removed the guard: nothing to undo. - layers.delete(top!); - top!.close(); + // The user's Back consumed guards: close as many top layers. The others + // keep their guards, so nothing is pushed while handling Back. + while (layers.size > depth) { + let top: Layer | undefined; + for (const layer of layers) + if ( + !top || + layer.level > top.level || + (layer.level === top.level && layer.order > top.order) + ) + top = layer; + layers.delete(top!); + top!.close(); + } scheduleReconcile(); }); } From 4f68676ae75370ef7e632eebc197dea09fda4219 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Sun, 18 Oct 2026 22:13:40 +0800 Subject: [PATCH 2/2] Cross all obsolete layer guards during Forward navigation --- web/src/systemBack.test.ts | 29 +++++++++++++++++++++++++++++ web/src/systemBack.ts | 17 +++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/web/src/systemBack.test.ts b/web/src/systemBack.test.ts index 5503d9b9..4cde18e0 100644 --- a/web/src/systemBack.test.ts +++ b/web/src/systemBack.test.ts @@ -292,6 +292,35 @@ async function registerDomTests() { expect(at()).toBe("/a"); }); + test("Forward crosses every empty layer guard at the end, and one Back leaves", async () => { + await start(); + await set(["composer", "sheet"]); + await set([]); + const length = history.length; + history.forward(); + await settle(); + expect([at(), history.length]).toEqual(["/b", length]); + expect(history.state).toMatchObject({ roamgateBack: 2 }); + await userBack(); + expect(at()).toBe("/a"); + }); + + test("Forward crosses every empty layer guard to a later entry", async () => { + await start(); + await set(["composer", "sheet"]); + history.pushState({ later: true }, "", "/c"); + await set([]); + await userBack(); + expect([at(), guarded()]).toEqual(["/b", false]); + history.forward(); + await settle(); + expect(at()).toBe("/c"); + await userBack(); + expect([at(), guarded()]).toEqual(["/b", false]); + await userBack(); + expect(at()).toBe("/a"); + }); + test("Forward and Back cross an empty guard without trapping later entries", async () => { await start(); await set(["composer"]); diff --git a/web/src/systemBack.ts b/web/src/systemBack.ts index ecf12e6d..ba622745 100644 --- a/web/src/systemBack.ts +++ b/web/src/systemBack.ts @@ -137,16 +137,21 @@ if (typeof window !== "undefined") { } if (forwardBase !== undefined) { const id = forwardBase; - forwardBase = undefined; - if ( - layers.size === 0 && - !isGuard(event.state) && - baseId(event.state) === id - ) { + if (layers.size === 0 && baseId(event.state) === id) { + if (isGuard(event.state)) { + // A layer stack can leave several consecutive obsolete guards. + // Keep crossing them in the same direction, without pushing history. + if (guardDepth(event.state) > guardDepth(from)) + window.history.forward(); + else window.history.back(); + return; + } + forwardBase = undefined; backPending = true; window.history.back(); return; } + forwardBase = undefined; } const id = baseId(event.state); if (