Leaving the Plex player while one of its layers was up (the info panel when the film ended, the countdown under the back arrow) landed as a user Back: the overlay listener spent it closing the top layer and the player stayed. useHistorySubview's open/back now go through closeOverlaysThenNavigate, like setPage. The RECLOSE comment no longer cites the shared Plex entry.
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
// @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();
|
||
});
|
||
|
||
/** 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("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 in the 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, and the contract lets one entry hold several. When the
|
||
// Plex player kept its panels on one entry, a single call left the entry up and the navigation was
|
||
// dropped 1.5 s later — `i`, `d`, Shift+W in the player closed the info panel and nothing else.
|
||
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();
|
||
}
|
||
});
|
||
});
|
||
|
||
// See useHistorySubview's back: a full tab can have dropped the entry under an overlay's own. The
|
||
// reconcile then has no entry of ours to step back to, and a back() would leave the document.
|
||
describe("an overlay whose entry has no entry of this document below it", () => {
|
||
it("drops its mark from the entry instead of stepping back out of the document", async () => {
|
||
const back = stubBack();
|
||
const layer = mountLayer(() => {});
|
||
await settle();
|
||
expect(window.history.state?._ov).toBeDefined();
|
||
stubNavigation([false]);
|
||
layer.unmount();
|
||
await settle();
|
||
expect(back).not.toHaveBeenCalled();
|
||
expect(window.history.state?._ov).toBeUndefined();
|
||
});
|
||
|
||
it("counts its own back()s still in flight: of two entries closing at once, only the first steps", async () => {
|
||
// Under the two overlay entries: one entry of this document, then another document's.
|
||
const landing: (() => void)[] = [];
|
||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||
landing.push(() =>
|
||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })),
|
||
);
|
||
});
|
||
const lower = mountLayer(() => {});
|
||
const upper = mountLayer(() => {});
|
||
await settle();
|
||
stubNavigation([true, false]);
|
||
upper.unmount();
|
||
lower.unmount();
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
act(() => landing.forEach((land) => land())); // the one back() lands, the books are square again
|
||
});
|
||
|
||
it("still steps back over its entry when the one below is this document's", async () => {
|
||
const back = stubBack();
|
||
const layer = mountLayer(() => {});
|
||
await settle();
|
||
stubNavigation([true]);
|
||
layer.unmount();
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|