diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index 23cc87c1..df1dcaaa 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -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(); diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index fbbc571f..e9463e92 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -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. diff --git a/frontend/src/lib/history.dom.test.ts b/frontend/src/lib/history.dom.test.ts index 8e6e6df3..41160aec 100644 --- a/frontend/src/lib/history.dom.test.ts +++ b/frontend/src/lib/history.dom.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index 0654ead4..f6a9b98b 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -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 ? 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); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 28fcfd07..0129b6f3 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -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 { 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 { @@ -175,7 +198,13 @@ export function useHistorySubview( }, [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; + window.history.replaceState(st, ""); + } } syncWaiters.forEach((cb) => cb()); }