// @vitest-environment jsdom // // The overlay history stack in history.ts, driven through its observable effects: which close/exit // callback fires, how many programmatic back()s go out, and when a command finally runs. // // A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch does NOT // call history.back() (it only decrements its own historyDepth to match the entry the browser already // dropped), so a raw popstate reproduces it deterministically. A PROGRAMMATIC back() — a reconcile // reclaiming a closed overlay's entry — is stubbed to land its own popstate, so no real jsdom back() // is ever left in flight. The bookkeeping is module-global: every test must leave the stack, the // depth and the in-flight count at zero for the next one. import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { closeOverlaysThenNavigate, closeTopOverlayThenRun, useBackToClose, useBackToExit, } from "./history"; // Let the mount effect's coalesced reconcile microtask settle. const settle = () => act(async () => { await Promise.resolve(); }); // Past `afterClosing`'s hop onto a fresh task (a setTimeout 0). const tick = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); // A genuine user Back (browser already dropped the entry; popstate arrives with no pending back()). const userBack = () => act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); // A programmatic back() that lands its popstate at once — the browser does it 1–13 ms later. const stubBack = () => vi.spyOn(window.history, "back").mockImplementation(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); /** A Back-closable layer whose close really unmounts it, as a closing dialog does. */ function mountLayer(onClose: () => void) { const holder: { unmount?: () => void } = {}; const r = renderHook(() => useBackToClose(() => { onClose(); holder.unmount?.(); }), ); holder.unmount = r.unmount; return r; } afterEach(() => { vi.clearAllMocks(); }); describe("useBackToExit back-ladder", () => { it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => { const outer = vi.fn(); const inner = vi.fn(); const ro = renderHook(() => useBackToExit(true, outer)); await settle(); const ri = renderHook(() => useBackToExit(true, inner)); await settle(); await userBack(); // peels the topmost (inner) only expect(inner).toHaveBeenCalledTimes(1); expect(outer).not.toHaveBeenCalled(); await userBack(); // now peels the outer expect(outer).toHaveBeenCalledTimes(1); expect(inner).toHaveBeenCalledTimes(1); ro.unmount(); ri.unmount(); }); it("stacks above a useBackToClose layer — Back closes the exit layer before the close layer", async () => { const close = vi.fn(); const exit = vi.fn(); const rc = renderHook(() => useBackToClose(close)); await settle(); const re = renderHook(() => useBackToExit(true, exit)); await settle(); await userBack(); expect(exit).toHaveBeenCalledTimes(1); expect(close).not.toHaveBeenCalled(); await userBack(); expect(close).toHaveBeenCalledTimes(1); rc.unmount(); re.unmount(); }); it("an inactive layer registers nothing — Back falls straight through to the layer below it", async () => { const below = vi.fn(); const gated = vi.fn(); const rb = renderHook(() => useBackToExit(true, below)); await settle(); const rg = renderHook(() => useBackToExit(false, gated)); // gated: not active → no layer await settle(); await userBack(); expect(gated).not.toHaveBeenCalled(); expect(below).toHaveBeenCalledTimes(1); rb.unmount(); rg.unmount(); }); }); describe("closeOverlaysThenNavigate", () => { // Every in-app navigation goes through it (setPage, openChannel). The trap: closing an overlay // reclaims its Back-to-close entry with a programmatic back(), and a pushState made before that // lands is undone by it — so the command would do nothing but close. it("navigates synchronously when no overlay is open", () => { const navigate = vi.fn(); closeOverlaysThenNavigate(navigate); expect(navigate).toHaveBeenCalledTimes(1); }); it("closes every open overlay, topmost first, and navigates only once all their entries are back", async () => { const back = stubBack(); try { const closed: string[] = []; mountLayer(() => closed.push("player")); await settle(); mountLayer(() => closed.push("palette")); await settle(); const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, "")); act(() => closeOverlaysThenNavigate(navigate)); expect(closed).toEqual(["palette", "player"]); expect(navigate).not.toHaveBeenCalled(); // Not inside the popstate dispatch that settled it — a later listener would still apply the // OLD entry's page over the push. Asserted FROM such a later listener (registered after the // helper's own), not after `settle()`: that async act can flush the unmounts on a macrotask, // and the navigate task sometimes ran first — a 1-in-5 false failure (E11 S2b gate). const navigatedByPopstate: number[] = []; const laterListener = () => navigatedByPopstate.push(navigate.mock.calls.length); window.addEventListener("popstate", laterListener); try { await settle(); // the reconcile reclaims both entries in one go expect(back).toHaveBeenCalledTimes(2); await tick(); } finally { window.removeEventListener("popstate", laterListener); } expect(navigatedByPopstate).toEqual([0, 0]); expect(navigate).toHaveBeenCalledTimes(1); await tick(); expect(back).toHaveBeenCalledTimes(2); // nothing reclaims over the entry navigate pushed expect(window.history.state).toMatchObject({ sfPage: "channels" }); } finally { back.mockRestore(); } }); // An overlay's Back callback is a Back STEP: the Plex player keeps its panels on one entry and peels // one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`, // Shift+W in the player closed the info panel and nothing else (deep-review E11 round 1). it("asks again, a render apart, when one call peels only a step off the overlay", async () => { const back = stubBack(); try { let steps = 2; const calls: number[] = []; const holder: { unmount?: () => void } = {}; const r = renderHook(() => useBackToClose(() => { calls.push(steps); if (--steps === 0) holder.unmount?.(); }), ); holder.unmount = r.unmount; await settle(); const navigate = vi.fn(); act(() => closeOverlaysThenNavigate(navigate)); expect(calls).toEqual([2]); await act(async () => { await new Promise((res) => setTimeout(res, 300)); }); expect(calls).toEqual([2, 1]); // never called again once the entry is gone expect(navigate).toHaveBeenCalledTimes(1); } finally { back.mockRestore(); } }); // The old helper ran after "a popstate" OR a 250 ms fallback, whichever came first — so a reclaim // slower than the fallback undid the navigation. It must wait for the history, not the clock. it("waits for a back() still in flight instead of pushing under it", async () => { let land: (() => void) | undefined; const back = vi.spyOn(window.history, "back").mockImplementation(() => { land = () => window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); try { mountLayer(() => {}); await settle(); const navigate = vi.fn(); act(() => closeOverlaysThenNavigate(navigate)); await settle(); expect(back).toHaveBeenCalledTimes(1); await act(async () => { await new Promise((r) => setTimeout(r, 300)); // past where the old fallback fired }); expect(navigate).not.toHaveBeenCalled(); act(() => land?.()); await tick(); expect(navigate).toHaveBeenCalledTimes(1); } finally { back.mockRestore(); } }); // No such overlay exists today, but a future "discard your changes?" guard must not have the page // switched out from under it: dropping the command is the honest outcome. it("drops the command when an overlay does not actually close", async () => { const back = stubBack(); const stubborn = renderHook(() => useBackToClose(() => {})); await settle(); vi.useFakeTimers(); try { const navigate = vi.fn(); act(() => closeOverlaysThenNavigate(navigate)); act(() => { vi.advanceTimersByTime(2000); }); expect(navigate).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); stubborn.unmount(); await settle(); expect(back).toHaveBeenCalledTimes(1); // its entry is still reclaimed on the way out back.mockRestore(); } }); // Shift+W pressed on Home with the player open: setPage finds nothing to push. The player must // still close (owner decision 2026-09-09), and its entry must be reclaimed rather than orphaned — // an orphan is a later browser Back that changes nothing on screen. it("closes the overlay and reclaims its entry when the navigation turns out to go nowhere", async () => { const back = stubBack(); try { const closed = vi.fn(); mountLayer(closed); await settle(); const navigate = vi.fn(); // setPage's early return: already on that page act(() => closeOverlaysThenNavigate(navigate)); await settle(); await tick(); expect(closed).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1); } finally { back.mockRestore(); } }); // An overlay closed in the tick it opened never pushed an entry, so no popstate is coming at all. it("navigates when the overlay closes before its entry was ever pushed", async () => { const back = stubBack(); try { mountLayer(() => {}); // no settle: its reconcile is still queued const navigate = vi.fn(); act(() => closeOverlaysThenNavigate(navigate)); await settle(); await tick(); expect(navigate).toHaveBeenCalledTimes(1); expect(back).not.toHaveBeenCalled(); } finally { back.mockRestore(); } }); }); describe("closeTopOverlayThenRun", () => { // The palette's rows. Only the palette closes: a theme picked over the player must leave it open. it("closes only the top overlay and runs once its entry is back, leaving the one below", async () => { const back = stubBack(); try { const playerClosed = vi.fn(); mountLayer(playerClosed); await settle(); const palette = renderHook(() => useBackToClose(() => {})); await settle(); const run = vi.fn(); act(() => closeTopOverlayThenRun(() => palette.unmount(), run)); expect(run).not.toHaveBeenCalled(); await settle(); await tick(); expect(run).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that expect(playerClosed).not.toHaveBeenCalled(); await userBack(); // teardown: the player closes by a genuine Back expect(playerClosed).toHaveBeenCalledTimes(1); await settle(); } finally { back.mockRestore(); } }); it("runs exactly once, however many popstates follow", async () => { const back = stubBack(); try { const palette = renderHook(() => useBackToClose(() => {})); await settle(); const run = vi.fn(); act(() => closeTopOverlayThenRun(() => palette.unmount(), run)); await settle(); await tick(); await userBack(); await userBack(); await tick(); expect(run).toHaveBeenCalledTimes(1); } finally { back.mockRestore(); } }); });