Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions web/src/components/ThemedSelect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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());
Expand Down
23 changes: 23 additions & 0 deletions web/src/systemBack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof pushState>) => {
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"]);
Expand Down
73 changes: 46 additions & 27 deletions web/src/systemBack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>)[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<string, unknown>)[BASE];
Expand All @@ -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<string, unknown>;
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<string, unknown>) };
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();
}
Expand Down Expand Up @@ -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();
});
}
Loading