Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped). - Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from even when focus was still inside the view: the first attempt now waits one task, past the sub-view's own popstate listener (measured in Chrome: it left focus on <body>). - The restore listener is installed at load, so the first Back after a reload finds it; a push cancels a restore still waiting for the last landing's lists. - Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped the navigation. - j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from its slot when that slot is on screen. - An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search). - A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard, Lightbox): new layerStack `anyBlockingLayerAbove`. - "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct` that ActionMenu now uses too. - Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change; stale comments about PlayerModal's scroll lock and dialog contract.
332 lines
12 KiB
TypeScript
332 lines
12 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();
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|
||
});
|