fix(history): a step back never leaves the app for an older page
Chrome keeps 50 entries a tab, and when the list is full a new entry drops the oldest one the page left without a user gesture, which can be the page's own entry under a sub-view or an overlay. A programmatic back() then left the document; once it landed on an older entry whose restored Plex player started playing on its own. Every programmatic back() now asks the Navigation API first and, with no entry of this document below, settles in place: the sub-view turns to its root, an overlay's entry drops its mark, the channel page and the YouTube search clear.
This commit is contained in:
@@ -55,6 +55,20 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
||||
function stubNavigation(below: boolean[]) {
|
||||
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
|
||||
entries.push({ sameDocument: true });
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
|
||||
});
|
||||
}
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
describe("useBackToExit back-ladder", () => {
|
||||
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
|
||||
const outer = vi.fn();
|
||||
@@ -329,3 +343,48 @@ describe("closeTopOverlayThenRun", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// See useHistorySubview's back: a full tab can have dropped the entry under an overlay's own. The
|
||||
// reconcile then has no entry of ours to step back to, and a back() would leave the document.
|
||||
describe("an overlay whose entry has no entry of this document below it", () => {
|
||||
it("drops its mark from the entry instead of stepping back out of the document", async () => {
|
||||
const back = stubBack();
|
||||
const layer = mountLayer(() => {});
|
||||
await settle();
|
||||
expect(window.history.state?._ov).toBeDefined();
|
||||
stubNavigation([false]);
|
||||
layer.unmount();
|
||||
await settle();
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(window.history.state?._ov).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts its own back()s still in flight: of two entries closing at once, only the first steps", async () => {
|
||||
// Under the two overlay entries: one entry of this document, then another document's.
|
||||
const landing: (() => void)[] = [];
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||||
landing.push(() =>
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })),
|
||||
);
|
||||
});
|
||||
const lower = mountLayer(() => {});
|
||||
const upper = mountLayer(() => {});
|
||||
await settle();
|
||||
stubNavigation([true, false]);
|
||||
upper.unmount();
|
||||
lower.unmount();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
act(() => landing.forEach((land) => land())); // the one back() lands, the books are square again
|
||||
});
|
||||
|
||||
it("still steps back over its entry when the one below is this document's", async () => {
|
||||
const back = stubBack();
|
||||
const layer = mountLayer(() => {});
|
||||
await settle();
|
||||
stubNavigation([true]);
|
||||
layer.unmount();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,21 @@ const popTo = (search: string, state: unknown = null) =>
|
||||
});
|
||||
|
||||
beforeEach(() => setUrl("/"));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
||||
function stubNavigation(below: boolean[]) {
|
||||
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
|
||||
entries.push({ sameDocument: true });
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
|
||||
});
|
||||
}
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
describe("useHistorySubview seed", () => {
|
||||
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
|
||||
@@ -170,3 +184,40 @@ describe("useHistorySubview entry", () => {
|
||||
expect(window.history.state._sub).toEqual({ kind: "player" });
|
||||
});
|
||||
});
|
||||
|
||||
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
|
||||
// left by a pushState with no user gesture — and that can be this page's own entry under the
|
||||
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
|
||||
// player started playing on its own).
|
||||
describe("useHistorySubview back", () => {
|
||||
it("goes back when the entry below is this document's", () => {
|
||||
stubNavigation([true]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.back());
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("goes back as before where the browser has no Navigation API", () => {
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.back());
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns to the root in place when the entry below is another document's", () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
stubNavigation([false]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
const before = window.history.length;
|
||||
act(() => result.current.back());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.view).toBe("root");
|
||||
expect(window.history.state?._sub).toBe("root");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.history.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
import { useLatest } from "./useLatest";
|
||||
|
||||
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
||||
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
|
||||
@@ -96,7 +97,29 @@ export function rememberNavPosition(): Record<string, unknown> {
|
||||
return st;
|
||||
}
|
||||
|
||||
// --- Is the entry a back() lands on still ours? --------------------------------------------
|
||||
// Chrome keeps 50 entries a tab. When the list is full a new entry drops the OLDEST "skippable" one —
|
||||
// an entry the page left by a pushState without a user gesture (an auto-skip countdown, a card that
|
||||
// opened on its own) — not the oldest entry, so it can be this page's own entry under a sub-view.
|
||||
// A programmatic back() then leaves the document for whatever older page sits below (measured
|
||||
// 2026-10-07: an older entry whose restored Plex player started playing on its own). The Navigation
|
||||
// API says which document the entry a back() lands on belongs to — counting the back()s of ours still
|
||||
// in flight; a browser without it keeps the old assumption that our entries are all still there.
|
||||
interface NavigationLike {
|
||||
currentEntry: { index: number } | null;
|
||||
entries: () => readonly { sameDocument: boolean }[];
|
||||
}
|
||||
|
||||
/** Would one more history.back() still land in this document? */
|
||||
export function backStaysInDocument(): boolean {
|
||||
const nav = (window as { navigation?: NavigationLike }).navigation;
|
||||
const at = nav?.currentEntry?.index;
|
||||
if (nav == null || at == null) return true;
|
||||
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true;
|
||||
}
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
|
||||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||
export interface SubviewUrl<T> {
|
||||
@@ -175,7 +198,13 @@ export function useHistorySubview<T>(
|
||||
},
|
||||
[url],
|
||||
);
|
||||
const back = useCallback(() => window.history.back(), []);
|
||||
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
|
||||
// the root view in place — a step up the module, not out of the app.
|
||||
const rootRef = useLatest(root);
|
||||
const back = useCallback(() => {
|
||||
if (backStaysInDocument()) window.history.back();
|
||||
else open(rootRef.current, { replace: true });
|
||||
}, [open, rootRef]);
|
||||
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
|
||||
// can now write `[sub]` and mean it, instead of re-running on every parent render.
|
||||
return useMemo(() => ({ view, open, back }), [view, open, back]);
|
||||
@@ -228,8 +257,14 @@ function reconcileHistory(): void {
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
pendingProgrammatic++;
|
||||
window.history.back();
|
||||
if (backStaysInDocument()) {
|
||||
pendingProgrammatic++;
|
||||
window.history.back();
|
||||
} else {
|
||||
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
|
||||
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
window.history.replaceState(st, "");
|
||||
}
|
||||
}
|
||||
syncWaiters.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user