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:
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
/** 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");
|
||||
});
|
||||
|
||||
// The provider reads the signed-in account (the tab title) from the `me` cache.
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
@@ -206,6 +220,31 @@ describe("NavigationProvider · Back", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
||||
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
|
||||
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
|
||||
it("closes the channel page in place", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openChannel("UC1", "One"));
|
||||
stubNavigation([false]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.closeChannel());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.channelView).toBeNull();
|
||||
expect(window.location.search).not.toContain("chan=");
|
||||
});
|
||||
|
||||
it("leaves the YouTube search in place", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("cats"));
|
||||
stubNavigation([false]);
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · never under an open overlay", () => {
|
||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||
const { result } = render();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
import {
|
||||
backStaysInDocument,
|
||||
closeOverlaysThenNavigate,
|
||||
nextNavId,
|
||||
notifyNav,
|
||||
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
||||
// drop the ?chan param from the address bar.
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
||||
// entry's channel marks and the ?chan param.
|
||||
setChannelView(null);
|
||||
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
|
||||
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "", urlWithParams({ chan: null }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -143,9 +146,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const exitYtSearch = useCallback(() => {
|
||||
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
||||
if (window.history.state?._yt) window.history.back();
|
||||
else setYtSearch(null);
|
||||
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
setYtSearch(null);
|
||||
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
||||
|
||||
@@ -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