// @vitest-environment jsdom // // useHistorySubview: a module's active sub-view rides in history.state._sub, with optional address-bar // mirroring so a deep sub-view (Plex show/season/info/person/playlist) is bookmarkable. This backfills // the coverage a review found missing — the seed AND the popstate handler must share // the SAME URL fallback, so a popstate that lands on a bookmark entry (URL-only, no _sub — e.g. after // an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing // it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useBackToClose, useHistorySubview, type SubviewUrl } from "./history"; // A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors // verbatim to ?v=. decode rejects the empty string (→ null, i.e. not a valid sub-view). const url: SubviewUrl = { key: "v", encode: (v) => (v === "root" ? null : v), decode: (raw) => raw || null, }; const setUrl = (search: string, state: unknown = null) => window.history.replaceState(state, "", search || "/"); const popTo = (search: string, state: unknown = null) => act(() => { window.history.replaceState(state, "", search || "/"); window.dispatchEvent(new PopStateEvent("popstate", { state })); }); beforeEach(() => setUrl("/")); 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)", () => { setUrl("?v=a"); const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("a"); }); it("prefers history.state._sub over the URL param (in-app entry)", () => { setUrl("?v=a", { _sub: "b" }); const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("b"); }); it("falls back to root when neither state nor a decodable URL param is present", () => { setUrl("/"); const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("root"); }); it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => { setUrl("?v=a", { _sub: "fromState" }); const { result } = renderHook(() => useHistorySubview("root")); expect(result.current.view).toBe("fromState"); // A URL param with no url config must NOT leak in. setUrl("?v=a", null); const { result: r2 } = renderHook(() => useHistorySubview("root")); expect(r2.current.view).toBe("root"); }); }); describe("useHistorySubview open", () => { it("pushes state._sub and mirrors the URL via encode", () => { const { result } = renderHook(() => useHistorySubview("root", url)); act(() => result.current.open("a")); expect(result.current.view).toBe("a"); expect(window.history.state?._sub).toBe("a"); expect(window.location.search).toBe("?v=a"); }); it("clears the mirrored key when encode returns null (root/ephemeral view)", () => { setUrl("?v=a"); const { result } = renderHook(() => useHistorySubview("root", url)); act(() => result.current.open("root")); expect(result.current.view).toBe("root"); expect(window.history.state?._sub).toBe("root"); expect(window.location.search).toBe(""); // ?v dropped }); // A cross-module hand-off (Home plays a Plex title) arrives on an entry the page navigation // just pushed. Opening the sub-view on TOP of it stranded the user on a module root they never // visited — browser-Back from the player landed on the Plex Discover page instead of Home // (measured 2026-09-06). `replace` makes the sub-view BE that entry. it("replace rewrites the current entry instead of stacking a new one", () => { const before = window.history.length; const { result } = renderHook(() => useHistorySubview("root", url)); act(() => result.current.open("a", { replace: true })); expect(result.current.view).toBe("a"); expect(window.history.state?._sub).toBe("a"); expect(window.location.search).toBe("?v=a"); expect(window.history.length).toBe(before); // no new entry to Back through }); it("replace is idempotent — two opens of the same view are still one entry", () => { // Not a nicety: under StrictMode the jumpTo effect body runs TWICE before its `setJumpTo(null)` // re-renders, and two pushes of the identical sub-view left a duplicate whose Back visibly did // nothing. const before = window.history.length; const { result } = renderHook(() => useHistorySubview("root", url)); act(() => { result.current.open("a", { replace: true }); result.current.open("a", { replace: true }); }); expect(window.history.length).toBe(before); }); it("keeps the entry's own sfScroll id on a replace", () => { // It is the SAME entry, so it keeps its scroll identity; PageScroller keys offsets per // (entry, view), and the view key changed, so the sub-view still opens at the top. setUrl("/", { sfScroll: 7 }); const { result } = renderHook(() => useHistorySubview("root", url)); act(() => result.current.open("a", { replace: true })); expect(window.history.state?.sfScroll).toBe(7); }); }); describe("useHistorySubview popstate", () => { it("restores the sub-view from state._sub on Back/Forward", () => { const { result } = renderHook(() => useHistorySubview("root", url)); popTo("?v=a", { _sub: "a" }); expect(result.current.view).toBe("a"); }); it("restores from the ? URL when the landed entry has no _sub — the bookmark fix", () => { // Start in-app at root, then a popstate lands on a URL-only bookmark entry (no _sub). Reading // history.state alone would wrongly collapse to root; the shared URL fallback must decode ?v=b. const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("root"); popTo("?v=b", null); expect(result.current.view).toBe("b"); }); it("collapses to root when a popstate lands on an entry with neither _sub nor a decodable URL", () => { setUrl("?v=a", { _sub: "a" }); const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("a"); popTo("/", null); expect(result.current.view).toBe("root"); }); }); // A one-shot flag the view is OPENED with (the Plex player's `fromStart`, a card's // Restart) rode in history.state._sub, so a reload or a Forward onto that entry restarted from 0 // again — and the player's checkpoint then overwrote the resume point. `entry` says what the // history entry keeps of a view; the view itself still opens with the flag. describe("useHistorySubview entry", () => { type V = { kind: string; once?: boolean }; const withEntry: SubviewUrl = { key: "v", encode: () => null, decode: () => null, entry: (v) => ({ kind: v.kind }), }; it("opens the view with the flag, but the history entry keeps it without", () => { const { result } = renderHook(() => useHistorySubview({ kind: "root" }, withEntry)); act(() => result.current.open({ kind: "player", once: true })); expect(result.current.view).toEqual({ kind: "player", once: true }); expect(window.history.state._sub).toEqual({ kind: "player" }); // A reload of that entry reads what the entry kept. const { result: reloaded } = renderHook(() => useHistorySubview({ kind: "root" }, withEntry), ); expect(reloaded.current.view).toEqual({ kind: "player" }); }); it("keeps it out of a replaced entry too", () => { const { result } = renderHook(() => useHistorySubview({ kind: "root" }, withEntry)); act(() => result.current.open({ kind: "player", once: true }, { replace: true })); 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); }); // A sub-view left while an overlay of its own is still up — the Plex player's stats readout, a // panel, the shortcuts sheet: the step back used to land as a USER Back, so the overlay listener // spent it closing the top overlay and the sub-view stayed where it was. it("closes the overlays standing over it first, then steps back out of the sub-view", async () => { const { result } = renderHook(() => useHistorySubview("root", url)); act(() => result.current.open("player")); const closed = vi.fn(); const holder: { unmount?: () => void } = {}; const layer = renderHook(() => useBackToClose(() => { closed(); holder.unmount?.(); }), ); holder.unmount = layer.unmount; await act(async () => { await Promise.resolve(); }); expect(window.history.state?._ov).toBeDefined(); act(() => result.current.back()); await waitFor(() => expect(result.current.view).toBe("root")); expect(closed).toHaveBeenCalledTimes(1); expect(window.history.state?._ov).toBeUndefined(); }); 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); }); });