diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index c7d96658..3ce4e66e 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -63,7 +63,7 @@ describe("useHistorySubview seed", () => { }); it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => { - setUrl("?v=a", { _sub: "fromState" }); + setUrl("?v=a", { _sub: "fromState", _subOf: "" }); const { result } = renderHook(() => useHistorySubview("root")); expect(result.current.view).toBe("fromState"); // A URL param with no url config must NOT leak in. @@ -128,6 +128,55 @@ describe("useHistorySubview open", () => { }); }); +// One entry, two modules: the sign-in card mounts on the entry a Plex season stood on (signed out +// there), and Plex on one the card's register view stood on (signed in from it). Each reads only its +// own sub-view: another's is not one of its views (the card rendered a mode it has no button for). +describe("useHistorySubview on an entry another module's sub-view stood on", () => { + const other: SubviewUrl = { + key: "m", + encode: (v) => (v === "signin" ? null : v), + decode: (raw) => (raw === "register" ? raw : null), + }; + + it("opens at its own root, not on the other's view", () => { + const plex = renderHook(() => useHistorySubview("root", url)); + act(() => plex.result.current.open("season")); + plex.unmount(); + const card = renderHook(() => useHistorySubview("signin", other)); + expect(card.result.current.view).toBe("signin"); + }); + + it("the other way round too", () => { + const card = renderHook(() => useHistorySubview("signin", other)); + act(() => card.result.current.open("register")); + card.unmount(); + window.history.replaceState(window.history.state, "", "/"); + const plex = renderHook(() => useHistorySubview("root", url)); + expect(plex.result.current.view).toBe("root"); + }); + + it("an older build's entry, which says nothing: its own only when its address names its view", () => { + setUrl("?v=season", { _sub: "season" }); + expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe("signin"); + expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); + }); + + it("reads its own from an entry it rewrote in place, which no address names", () => { + const first = renderHook(() => useHistorySubview("root")); + act(() => first.result.current.open("x", { replace: true })); + first.unmount(); + expect(renderHook(() => useHistorySubview("root")).result.current.view).toBe("x"); + }); + + it("still reads its own", () => { + const plex = renderHook(() => useHistorySubview("root", url)); + act(() => plex.result.current.open("season")); + plex.unmount(); + window.history.replaceState(window.history.state, "", "/"); + expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); + }); +}); + describe("useHistorySubview popstate", () => { it("restores the sub-view from state._sub on Back/Forward", () => { const { result } = renderHook(() => useHistorySubview("root", url)); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 8086f7b7..182cb61c 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -139,6 +139,9 @@ export interface SubviewUrl { entry?: (view: T) => T; } +/** Whose sub-view an entry's `_sub` is: the key its module mirrors to (a state-only one has none). */ +const subOwner = (url: { key: string } | undefined): string => url?.key ?? ""; + export function useHistorySubview( root: T, url?: SubviewUrl, @@ -158,14 +161,18 @@ export function useHistorySubview( // ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a // bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and // reading history.state alone would wrongly collapse the bookmarked sub-view to root. + // The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another + // module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and + // took that season for one of its modes (a card with no button for it). An entry an older build + // wrote says nothing: its _sub is this module's when its address names one of this module's views. const readView = (): T => { - const fromState = window.history.state?._sub as T | undefined; + const st = window.history.state; + const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null; + const decoded = url && raw != null ? url.decode(raw) : null; + const owned = st?._subOf === undefined ? decoded != null : st._subOf === subOwner(url); + const fromState = owned ? (st?._sub as T | undefined) : undefined; if (fromState != null) return fromState; - if (url) { - const raw = new URLSearchParams(window.location.search).get(url.key); - const decoded = raw != null ? url.decode(raw) : null; - if (decoded != null) return decoded; - } + if (decoded != null) return decoded; return root; }; const [view, setView] = useState(readView); @@ -211,7 +218,7 @@ export function useHistorySubview( // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. window.history.pushState( - { ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() }, + { ...rememberNavPosition(), _sub: kept, _subOf: subOwner(url), sfScroll: nextNavId() }, "", href, ); @@ -253,7 +260,7 @@ export function useHistorySubview( function writeSubEntry(next: T, url: SubviewUrl | undefined): void { const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; - replaceView({ ...window.history.state, _sub: kept }, href); + replaceView({ ...window.history.state, _sub: kept, _subOf: subOwner(url) }, href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- diff --git a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx index 18af5433..152c2412 100644 --- a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx +++ b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx @@ -45,7 +45,7 @@ beforeAll(() => { globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); -beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, "")); +beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" }, _subOf: "v" }, "")); afterEach(() => { cleanup(); vi.restoreAllMocks();