// @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 E4 deep-review's deferred coverage for the S2 fix — 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 } from "@testing-library/react"; import { 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.clearAllMocks()); 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 }); // E24: 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 S2 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"); }); });