// @vitest-environment jsdom // // Component-test lane pilot: the useBackToExit "back-ladder". This is the first jsdom/renderHook // test in the repo (the pure-node lane can't touch window.history / popstate). It exercises the // module-level overlay stack in history.ts through its only observable effect — which onExit fires. // // A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch in // history.ts 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 without // depending on jsdom's async back() semantics. Each test closes its layers via genuine Backs, so // no reconcile-driven history.back() runs and the shared module state stays clean between tests. import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { closeThenNavigate, useBackToClose, useBackToExit } from "./history"; // Let the mount effect's coalesced reconcile microtask settle. const settle = () => act(async () => { await Promise.resolve(); }); // 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 })); }); 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("closeThenNavigate", () => { // The bug it exists for: an overlay's unmount reclaims its Back-to-close entry with a programmatic // history.back(). A pushState made before that lands is undone by it, so a dialog command that // navigates does nothing but close. PlayerModal hit it once; the command palette hit it again. it("closes first and navigates only after the reclaim lands", () => { const order: string[] = []; closeThenNavigate( () => order.push("close"), () => order.push("navigate"), ); expect(order).toEqual(["close"]); window.dispatchEvent(new PopStateEvent("popstate")); expect(order).toEqual(["close", "navigate"]); }); it("navigates exactly once, however many pops follow", () => { const navigate = vi.fn(); closeThenNavigate(() => {}, navigate); window.dispatchEvent(new PopStateEvent("popstate")); window.dispatchEvent(new PopStateEvent("popstate")); expect(navigate).toHaveBeenCalledTimes(1); }); // E10 review round 1. Waiting for ONE reclaim is not enough when a SECOND Back-closable layer is // still mounted and the navigation is what unmounts it: that layer's own reconcile fired another // history.back(), which popped the entry the navigation had just pushed and bounced the user back // where they started (palette opened over the player → "go to Channels" landed on the feed). // // NOTE this one may NOT use the raw-popstate shortcut the rest of the file uses: a synthetic // popstate is read as a GENUINE Back, which pops the lower layer off the stack and decrements // historyDepth by itself — so the test would pass with or without the fix. The bug only appears // on the programmatic path, so back() is stubbed to land its own popstate instead. it("adopts the entries of layers the navigation itself unmounts", async () => { const back = vi.spyOn(window.history, "back").mockImplementation(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); try { const player = renderHook(() => useBackToClose(() => {})); await settle(); const palette = renderHook(() => useBackToClose(() => {})); await settle(); expect(back).not.toHaveBeenCalled(); // both layers hold an entry; nothing reclaimed yet const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, "")); await act(async () => { closeThenNavigate(() => palette.unmount(), navigate); await Promise.resolve(); // let the palette's own reclaim reconcile run }); expect(navigate).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that player.unmount(); // …which is what the navigation does to the layer below await settle(); expect(back).toHaveBeenCalledTimes(1); // must NOT back over the entry navigate just pushed } finally { back.mockRestore(); } }); // Without this the command is not late, it never happens: an overlay closed in the same tick it // opened never pushed an entry, so no reclaim popstate is ever coming. it("navigates anyway when no reclaim popstate ever arrives", () => { vi.useFakeTimers(); try { const navigate = vi.fn(); closeThenNavigate(() => {}, navigate); expect(navigate).not.toHaveBeenCalled(); vi.advanceTimersByTime(300); expect(navigate).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); });