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.
This commit is contained in:
@@ -21,7 +21,7 @@ beforeAll(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before
|
||||
// running the command (see closeThenNavigate), so the command lands a tick later — hence the waits.
|
||||
// running the command (see closeTopOverlayThenRun), so the command lands a tick later — hence the waits.
|
||||
const ran: string[] = [];
|
||||
const groups = (): CommandGroup[] => [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
|
||||
// (see closeThenNavigate), 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. (It is not
|
||||
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the
|
||||
// other file that asserts on one, and any test rendering a real Modal can let one go.)
|
||||
// (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
|
||||
@@ -50,7 +49,7 @@ function Harness() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommandPalette · closeThenNavigate ordering", () => {
|
||||
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
|
||||
@@ -62,8 +61,9 @@ describe("CommandPalette · closeThenNavigate ordering", () => {
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
|
||||
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the
|
||||
// command — not the safety net that exists for overlays which never got an entry at all.
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 });
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeThenNavigate } from "../../lib/history";
|
||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
@@ -92,9 +92,11 @@ export default function CommandPalette({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands
|
||||
// navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate.
|
||||
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run);
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
|
||||
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
|
||||
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
|
||||
// leave it standing. See closeTopOverlayThenRun.
|
||||
const choose = (item: CommandItem) => closeTopOverlayThenRun(onClose, item.run);
|
||||
|
||||
// These live on the input rather than in the shared keymap: they are the field's own editing keys
|
||||
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
|
||||
|
||||
Reference in New Issue
Block a user