Files
siftlode/frontend/src/lib/history.dom.test.ts
T
peter f130a1c05a fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs
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.
2026-09-13 17:17:49 +02:00

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
}
});
});