Files
siftlode/frontend/src/components/ui/CommandPalette.ordering.dom.test.tsx
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

70 lines
2.8 KiB
TypeScript

// @vitest-environment jsdom
//
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
// (see closeTopOverlayThenRun), and it ASSERTS on a real useBackToClose entry driven through a real
// programmatic history.back() — which is why it cannot share module state with anything. (Any test
// rendering a real Modal can let an unstubbed back() go, too.)
//
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history,
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
// in-flight programmatic back() behind — under which this one either hangs or, worse, goes green on
// somebody else's popstate. That is exactly how it used to pass: it asserted nothing on its own, and
// running it with `-t` failed (E10 review round 1). Its own file gives it clean module state.
import { useState } from "react";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import CommandPalette, { type CommandGroup } from "./CommandPalette";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
beforeAll(() => {
// jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches.
Element.prototype.scrollIntoView ??= () => {};
});
const ran: string[] = [];
function Harness() {
const [isOpen, setIsOpen] = useState(true);
const groups: CommandGroup[] = [
{
id: "channels",
label: "Channels",
items: [{ id: "ch:1", label: "TheVR Podcast", run: () => ran.push("ch:1") }],
},
];
return (
<CommandPalette
open={isOpen}
onClose={() => {
ran.push("closed");
setIsOpen(false);
}}
groups={groups}
/>
);
}
describe("CommandPalette · close-then-run ordering", () => {
it("closes, and runs the command only once the close has landed", async () => {
render(<Harness />);
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the
// same synchronous tick as the render finds no entry to reclaim — no back(), no popstate.
await act(async () => {
await Promise.resolve();
});
fireEvent.click(screen.getByText("TheVR Podcast"));
expect(ran).toEqual(["closed"]);
// Well inside closeTopOverlayThenRun's 1500 ms give-up timer, so a pass means the RECLAIM ran the
// command — not the late path for a back() that never reports. (The margin used to be 200 ms
// under a 250 ms fallback, which made this load-sensitive by construction.)
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 1000 });
});
});