The Plex player's ⋯ → Settings closed the menu (its entry went back) and opened the panel a task later, before that back() had landed. The panel's push was the entry the landing back() took, so the books counted one entry more than the history held, and closing the panel by a click outside it stepped back over the player's own entry: the player closed. A push now waits while a back() of ours is in flight; the popstate that lands the last one reconciles again.
438 lines
16 KiB
TypeScript
438 lines
16 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();
|
||
}
|
||
});
|
||
|
||
// The Plex player's ⋯ → Settings: the menu closes (its entry goes back) and the panel opens a task
|
||
// later, before that back() has landed. A push made then is the entry the landing back() takes, so
|
||
// the books said one entry stood that did not — and the panel's own close stepped back over the
|
||
// player's entry, closing the player on a click outside the panel.
|
||
it("an overlay opened while a back() is in flight pushes its entry only once that back() lands", async () => {
|
||
const landing: (() => void)[] = [];
|
||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||
landing.push(() =>
|
||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })),
|
||
);
|
||
});
|
||
const land = () => act(() => landing.splice(0).forEach((l) => l()));
|
||
const push = vi.spyOn(window.history, "pushState");
|
||
let panel: { unmount: () => void } | undefined;
|
||
try {
|
||
const menu = mountLayer(() => {});
|
||
await settle();
|
||
menu.unmount();
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
|
||
push.mockClear();
|
||
panel = mountLayer(() => {});
|
||
await tick();
|
||
expect(push).not.toHaveBeenCalled(); // it would be undone by the landing back()
|
||
|
||
land();
|
||
await settle();
|
||
expect(push).toHaveBeenCalledTimes(1);
|
||
|
||
// The books are square: closing the panel reclaims its own entry, one back() and no more.
|
||
back.mockClear();
|
||
panel.unmount();
|
||
panel = undefined;
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
// Square the module-global books for the next test, however far this one got.
|
||
panel?.unmount();
|
||
await settle();
|
||
land();
|
||
await settle();
|
||
back.mockRestore();
|
||
push.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);
|
||
});
|
||
});
|