Files
siftlode/frontend/src/lib/history.dom.test.ts
T
peter 92c305bab9 fix(nav): a navigation closes every overlay before it pushes
The overlay history stack let a navigation land ABOVE open overlays and
"buried" their entries with a counter. Measured in Chrome: Shift+W over
About navigated under the still-open dialog, the next Back both undid the
navigation and closed About, and the Back after that did nothing; Shift+W
on Home with the player open did nothing at all. The counter could also be
spent by a different overlay closing first, and callers that could not
report whether they pushed were assumed to have.

setPage and openChannel now close every open overlay and push only once
the history is back in sync (one entry per live overlay, no programmatic
back() in flight, no reconcile queued), so nothing is ever buried and the
whole mechanism goes: navigateOverOverlays, closeThenNavigate, buryUnder,
setPage's boolean, and the three test files that pinned them. The palette
closes only itself before running a row, so a theme picked over the player
keeps the player. Shift+W on Home with the player open now closes it
(owner decision 2026-09-09).

The wait is on state, not on "a popstate": the old 250 ms fallback could
run with back()s still in flight. An overlay that refuses to close within
1.5 s drops the command instead of being navigated out from under.
2026-09-13 00:17:09 +02:00

294 lines
10 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();
await settle(); // the reconcile reclaims both entries in one go
expect(back).toHaveBeenCalledTimes(2);
// Not inside the popstate dispatch that settled it — a later listener would still apply the
// OLD entry's page over the push.
expect(navigate).not.toHaveBeenCalled();
await tick();
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();
}
});
// 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();
}
});
});