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 2a763637..4cde18e0 100644 --- a/web/src/systemBack.test.ts +++ b/web/src/systemBack.test.ts @@ -209,6 +209,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("Tabs and More replacement shares one Back guard above the current view", async () => { await start(); await set(["files", "sheet"]); @@ -269,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 553321de..ba622745 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(); } @@ -122,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 ( @@ -147,21 +167,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(); }); }