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:
2026-09-13 00:17:09 +02:00
parent 32fe0768b6
commit 92c305bab9
14 changed files with 449 additions and 465 deletions
-33
View File
@@ -1,33 +0,0 @@
// The global Shift+W wiring, pinned at its call site.
//
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
// check, because jsdom reproduces history ordering only as bookkeeping.
//
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
// straight back (round 2, verified by running that revert).
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
describe("App · global keymap wiring", () => {
it("routes the global home shortcut through navigateOverOverlays", () => {
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
// A rename or a move is a legitimate change — but then this test is the thing that has to be
// updated deliberately, which is the whole point of pinning it.
expect(
call,
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
).toBeTypeOf("string");
expect(call).toContain("navigateOverOverlays");
expect(call).toContain('setPage("home")');
});
});
+6 -10
View File
@@ -37,7 +37,6 @@ import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp"; import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette"; import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap"; import { useGlobalKeymap } from "./lib/keymap";
import { navigateOverOverlays } from "./lib/history";
import { StateMessage } from "./components/ui/QueryState"; import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
@@ -142,13 +141,10 @@ export default function App() {
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
// above the signed-out branch is worse than not binding it. // above the signed-out branch is worse than not binding it.
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does // A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile // so Shift+W over the player lands on Home with the player closed — also when Home is already the
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the // page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not useGlobalKeymap(!!meQuery.data, () => setPage("home"));
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked // Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER // an account (it was riding the session's default) would silently swap identity when ANOTHER
@@ -279,8 +275,8 @@ export default function App() {
label: t("common.accessRequestsReview"), label: t("common.accessRequestsReview"),
onClick: () => { onClick: () => {
focusAccessRequestsTab(); focusAccessRequestsTab();
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav). // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
navigateOverOverlays(() => setPage("users")); setPage("users");
}, },
}, },
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ import { urlWithParams } from "../lib/urlState";
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab // The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because // lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own // the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see closeThenNavigate). // Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & { type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
channelId: string; channelId: string;
title: string | null | undefined; title: string | null | undefined;
@@ -1,16 +1,14 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered // The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
// before this. `setPage` early-returns when the requested page is already current with no channel // under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to // `setPage`'s same-page guard, which decides whether anything is pushed at all.
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
// the user's next browser Back does nothing.
// //
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one // The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail // palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the // actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
// early return skips, which is what the boolean actually stands for. // then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
// bookkeeping.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react"; import { renderHook, act, cleanup } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider"; import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
import { useBackToClose } from "../lib/history";
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
<NavigationProvider>{children}</NavigationProvider> <NavigationProvider>{children}</NavigationProvider>
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
const render = () => const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
const settle = () =>
act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
* land its popstate at once. */
async function openOverlay() {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
const holder: { unmount?: () => void } = {};
const onClose = vi.fn(() => holder.unmount?.());
const layer = renderHook(() => useBackToClose(onClose));
holder.unmount = layer.unmount;
await settle();
return { back, onClose };
}
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
@@ -41,46 +59,29 @@ beforeEach(() => {
}); });
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a // Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does. // provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(cleanup); afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("NavigationProvider · setPage reports whether it navigated", () => { describe("NavigationProvider · setPage's same-page guard", () => {
it("returns true and pushes an entry for a real page change", () => { it("pushes an entry for a real page change", () => {
const { result } = render(); const { result } = render();
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage("plex"));
let reported: boolean | undefined; expect(push).toHaveBeenCalledTimes(1);
act(() => { expect(result.current.page).toBe("plex");
reported = result.current.setPage("plex");
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
} finally {
push.mockRestore();
}
}); });
it("returns false and pushes nothing when it is already that page", () => { it("pushes nothing when it is already that page", () => {
const { result } = render(); const { result } = render();
act(() => { act(() => result.current.setPage("plex"));
result.current.setPage("plex");
});
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage("plex"));
let reported: boolean | undefined; expect(push).not.toHaveBeenCalled();
act(() => {
reported = result.current.setPage("plex");
});
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
// that answer true. A value-only assertion would pass against an inverted pair.
expect(reported).toBe(false);
expect(push).not.toHaveBeenCalled();
} finally {
push.mockRestore();
}
}); });
it("returns true even for the current page when a channel page is over it", () => { it("still navigates to the current page when a channel page is over it", () => {
// The nav rail's own module while a channel page is open over it is a real navigation: it closes // The nav rail's own module while a channel page is open over it is a real navigation: it closes
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one. // the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render(); const { result } = render();
@@ -90,22 +91,64 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
// asking for "feed" was just an ordinary page change and deleting the clause left it green. // asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page; const current = result.current.page;
expect(current).toBe("home"); expect(current).toBe("home");
act(() => { act(() => result.current.openChannel("UC1", "A channel"));
result.current.openChannel("UC1", "A channel");
});
expect(result.current.channelView).not.toBeNull(); expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage(current));
let reported: boolean | undefined; expect(push).toHaveBeenCalledTimes(1);
act(() => { expect(result.current.channelView).toBeNull();
reported = result.current.setPage(current); });
}); });
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1); describe("NavigationProvider · never under an open overlay", () => {
expect(result.current.channelView).toBeNull(); it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
} finally { const { result } = render();
push.mockRestore(); const { back, onClose } = await openOverlay();
} const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(result.current.page));
await settle();
await settle();
expect(onClose).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
// The player's channel link and the palette's channel rows.
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.openChannel("UC1", "A channel"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
expect(window.history.state).toMatchObject({ _chan: "UC1" });
}); });
}); });
+55 -46
View File
@@ -14,7 +14,7 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage"; import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav"; import { setNavigator } from "../lib/nav";
import { nextNavId, notifyNav } from "../lib/history"; import { closeOverlaysThenNavigate, nextNavId, notifyNav } from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit"; import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages // Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -37,10 +37,11 @@ export interface NavigationState {
} }
export interface NavigationActions { export interface NavigationActions {
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page /** Switch modules. Closes every open overlay first and pushes only once their history entries are
* is already current with no channel page over it, and a caller that buries overlay history * back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */ * after the call; with nothing open it is synchronous. The requested page being already current
setPage: (next: Page) => boolean; * (with no channel page over it) pushes nothing — but the overlays still close. */
setPage: (next: Page) => void;
openChannel: (id: string, name?: string) => void; openChannel: (id: string, name?: string) => void;
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
@@ -53,7 +54,7 @@ const StateContext = createContext<NavigationState>({
ytSearch: null, ytSearch: null,
}); });
const ActionsContext = createContext<NavigationActions>({ const ActionsContext = createContext<NavigationActions>({
setPage: () => false, setPage: () => {},
openChannel: () => {}, openChannel: () => {},
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
@@ -118,53 +119,61 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}); });
const setPage = useCallback( const setPage = useCallback(
(next: Page) => { (next: Page) =>
// A channel page overlays the content column, so a nav click must close it even when the // Never under an open overlay — see the interface doc. The refs are read when the push
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). // actually happens, which over an open player is a moment after this call.
if (next === pageRef.current && !channelViewRef.current) return false; closeOverlaysThenNavigate(() => {
markDirty(); // A channel page overlays the content column, so a nav click must close it even when the
setChannelView(null); // leave any open channel page when navigating via the rail // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
setPageState(next); if (next === pageRef.current && !channelViewRef.current) return;
setAccountRaw(PAGE_KEY, next); markDirty();
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the setChannelView(null); // leave any open channel page when navigating via the rail
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and setPageState(next);
// the address bar reflects the page (feed is implicit) while clearing the view-specific params setAccountRaw(PAGE_KEY, next);
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page. // root, and the address bar reflects the page (feed is implicit) while clearing the
window.history.pushState( // view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
{ sfPage: next, sfScroll: nextNavId() }, // switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
"", // intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
urlWithParams({ // their page.
page: next === "feed" ? null : next, window.history.pushState(
chan: null, { sfPage: next, sfScroll: nextNavId() },
v: null, "",
pl: null, urlWithParams({
pscope: null, page: next === "feed" ? null : next,
psort: null, chan: null,
pshow: null, v: null,
pf: null, pl: null,
ctab: null, pscope: null,
csort: null, psort: null,
cf: null, pshow: null,
}), pf: null,
); ctab: null,
notifyNav(); csort: null,
return true; cf: null,
}, }),
);
notifyNav();
}),
[markDirty], [markDirty],
); );
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a // A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's // pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page. // replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
//
// Like setPage it never lands under an open overlay: the player's channel link and the palette's
// channel rows call it from inside one.
const openChannel = useCallback((id: string, name?: string) => { const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name }); closeOverlaysThenNavigate(() => {
const st = window.history.state || {}; setChannelView({ id, name });
const url = urlWithParams({ chan: id }); const st = window.history.state || {};
if (st._chan) const url = urlWithParams({ chan: id });
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); if (st._chan)
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
});
}, []); }, []);
const closeChannel = useCallback(() => { const closeChannel = useCallback(() => {
+6 -8
View File
@@ -46,7 +46,7 @@ import {
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen"; import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink"; import ChannelNameLink from "./ChannelNameLink";
import Toaster, { useOverlayOrder } from "./ui/Toaster"; import Toaster, { useOverlayOrder } from "./ui/Toaster";
import { afterRemoval } from "../lib/playerQueue"; import { afterRemoval } from "../lib/playerQueue";
@@ -822,13 +822,11 @@ export default function PlayerModal({
channelId={active.channel_id} channelId={active.channel_id}
title={active.channel_title} title={active.channel_title}
onOpenInApp={() => { onOpenInApp={() => {
const cid = active.channel_id; // openChannel closes this player first and pushes only once its
const cname = active.channel_title ?? undefined; // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// Close first, open the channel only once this player's own Back-to-close // and clicking the name merely closes the player
// entry has been reclaimed — otherwise that reclaim undoes the push and // (lib/history.closeOverlaysThenNavigate).
// clicking the name merely closes the player. The rule lives in history.ts openChannel(active.channel_id, active.channel_title ?? undefined);
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}} }}
className="font-medium hover:text-accent truncate text-left" className="font-medium hover:text-accent truncate text-left"
/> />
@@ -21,7 +21,7 @@ beforeAll(() => {
afterEach(cleanup); afterEach(cleanup);
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before // `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 ran: string[] = [];
const groups = (): CommandGroup[] => [ const groups = (): CommandGroup[] => [
{ {
@@ -1,10 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette // 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 // (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. (It is not // programmatic history.back() — which is why it cannot share module state with anything. (Any test
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the // rendering a real Modal can let an unstubbed back() go, too.)
// other file that asserts on one, and any test rendering a real Modal can let one go.)
// //
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history, // 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 // 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 () => { it("closes, and runs the command only once the close has landed", async () => {
render(<Harness />); render(<Harness />);
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the // 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")); fireEvent.click(screen.getByText("TheVR Podcast"));
expect(ran).toEqual(["closed"]); expect(ran).toEqual(["closed"]);
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the // Well inside closeTopOverlayThenRun's 1500 ms give-up timer, so a pass means the RECLAIM ran the
// command — not the safety net that exists for overlays which never got an entry at all. // command — not the late path for a back() that never reports. (The margin used to be 200 ms
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 }); // 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 { useTranslation } from "react-i18next";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import Modal from "./Modal"; 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 // 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 // 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; if (!open) return null;
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands // 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. // navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run); // 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 // 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. // (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
@@ -1,43 +0,0 @@
// @vitest-environment jsdom
//
// ONE test, ONE file, for the same reason CommandPalette.ordering.dom.test.tsx has its own: it
// ASSERTS on a reconcile issuing a REAL, unstubbed programmatic history.back(), and history.ts's
// overlay bookkeeping is module-global. history.dom.test.ts keeps its shared state clean by never
// leaving a real back() in flight — its layers close by a genuine Back, a stubbed back(), or a
// consumed burial mark — and this test would break that.
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { navigateOverOverlays, useBackToClose } from "./history";
const settle = () =>
act(async () => {
await Promise.resolve();
});
describe("navigateOverOverlays · burying a navigation that never happened", () => {
// The round-2 defect. `navigateOverOverlays` marked every live overlay entry buried BEFORE it knew
// whether the navigation pushed anything, and `setPage` early-returns when the requested page is
// already current. Home is the default landing page, so Shift+W pressed while already on Home with
// the player open buried the player's entry under a push that never happened: its unmount then
// consumed the mark instead of reclaiming the entry with history.back(), leaving an orphan. The
// user's next browser Back popped onto an identical `sfPage` with an empty overlay stack — a press
// that changed nothing on screen, one more per play → Shift+W → close cycle.
it("leaves the overlay's entry reclaimable when the navigation reports it pushed nothing", async () => {
const overlay = renderHook(() => useBackToClose(() => {}));
await settle();
const back = vi.spyOn(window.history, "back");
try {
// What `setPage` returns from its early return: asked for the page we are already on.
const navigate = vi.fn(() => false);
act(() => navigateOverOverlays(navigate));
expect(navigate).toHaveBeenCalledTimes(1);
overlay.unmount();
await settle();
// Nothing was pushed over it, so the entry is still ours — reclaimed, not silently orphaned.
expect(back).toHaveBeenCalledTimes(1);
} finally {
back.mockRestore();
}
});
});
+166 -101
View File
@@ -1,32 +1,55 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// Component-test lane pilot: the useBackToExit "back-ladder". This is the first jsdom/renderHook // The overlay history stack in history.ts, driven through its observable effects: which close/exit
// test in the repo (the pure-node lane can't touch window.history / popstate). It exercises the // callback fires, how many programmatic back()s go out, and when a command finally runs.
// module-level overlay stack in history.ts through its only observable effect — which onExit fires.
// //
// A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch in // A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch does NOT
// history.ts does NOT call history.back() (it only decrements its own historyDepth to match the // call history.back() (it only decrements its own historyDepth to match the entry the browser already
// entry the browser already dropped), so a raw popstate reproduces it deterministically without // dropped), so a raw popstate reproduces it deterministically. A PROGRAMMATIC back() — a reconcile
// depending on jsdom's async back() semantics. The invariant that keeps the module-global // reclaiming a closed overlay's entry — is stubbed to land its own popstate, so no real jsdom back()
// bookkeeping clean between tests here is NOT "only genuine Backs" — two closeThenNavigate cases // is ever left in flight. The bookkeeping is module-global: every test must leave the stack, the
// deliberately drive a reconcile into history.back() — it is that no REAL back() is ever left in // depth and the in-flight count at zero for the next one.
// flight: a layer is closed by a genuine Back, by a STUBBED back() that lands its own popstate, or
// by an unmount whose burial mark is consumed. A test that needs an unstubbed one belongs in its own
// file (see history.burial.dom.test.ts).
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react"; import { renderHook, act } from "@testing-library/react";
import { closeThenNavigate, navigateOverOverlays, useBackToClose, useBackToExit } from "./history"; import {
closeOverlaysThenNavigate,
closeTopOverlayThenRun,
useBackToClose,
useBackToExit,
} from "./history";
// Let the mount effect's coalesced reconcile microtask settle. // Let the mount effect's coalesced reconcile microtask settle.
const settle = () => const settle = () =>
act(async () => { act(async () => {
await Promise.resolve(); 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()). // A genuine user Back (browser already dropped the entry; popstate arrives with no pending back()).
const userBack = () => const userBack = () =>
act(() => { act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); 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(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -92,137 +115,179 @@ describe("useBackToExit back-ladder", () => {
}); });
}); });
describe("closeThenNavigate", () => { describe("closeOverlaysThenNavigate", () => {
// The bug it exists for: an overlay's unmount reclaims its Back-to-close entry with a programmatic // Every in-app navigation goes through it (setPage, openChannel). The trap: closing an overlay
// history.back(). A pushState made before that lands is undone by it, so a dialog command that // reclaims its Back-to-close entry with a programmatic back(), and a pushState made before that
// navigates does nothing but close. PlayerModal hit it once; the command palette hit it again. // lands is undone by it — so the command would do nothing but close.
it("closes first and navigates only after the reclaim lands", () => { it("navigates synchronously when no overlay is open", () => {
const order: string[] = [];
closeThenNavigate(
() => order.push("close"),
// Braced: `navigate` reports whether it PUSHED (see `buryUnder`), so a bare `push` returning
// an array length is a type error rather than an accidental "yes".
() => {
order.push("navigate");
},
);
expect(order).toEqual(["close"]);
window.dispatchEvent(new PopStateEvent("popstate"));
expect(order).toEqual(["close", "navigate"]);
});
it("navigates exactly once, however many pops follow", () => {
const navigate = vi.fn(); const navigate = vi.fn();
closeThenNavigate(() => {}, navigate); closeOverlaysThenNavigate(navigate);
window.dispatchEvent(new PopStateEvent("popstate"));
window.dispatchEvent(new PopStateEvent("popstate"));
expect(navigate).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledTimes(1);
}); });
// E10 review round 1. Waiting for ONE reclaim is not enough when a SECOND Back-closable layer is it("closes every open overlay, topmost first, and navigates only once all their entries are back", async () => {
// still mounted and the navigation is what unmounts it: that layer's own reconcile fired another const back = stubBack();
// history.back(), which popped the entry the navigation had just pushed and bounced the user back
// where they started (palette opened over the player → "go to Channels" landed on the feed).
//
// NOTE this one may NOT use the raw-popstate shortcut the rest of the file uses: a synthetic
// popstate is read as a GENUINE Back, which pops the lower layer off the stack and decrements
// historyDepth by itself — so the test would pass with or without the fix. The bug only appears
// on the programmatic path, so back() is stubbed to land its own popstate instead.
it("adopts the entries of layers the navigation itself unmounts", async () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
try { try {
const player = renderHook(() => useBackToClose(() => {})); const closed: string[] = [];
mountLayer(() => closed.push("player"));
await settle(); await settle();
const palette = renderHook(() => useBackToClose(() => {})); mountLayer(() => closed.push("palette"));
await settle(); await settle();
expect(back).not.toHaveBeenCalled(); // both layers hold an entry; nothing reclaimed yet
const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, "")); const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, ""));
await act(async () => { act(() => closeOverlaysThenNavigate(navigate));
closeThenNavigate(() => palette.unmount(), navigate); expect(closed).toEqual(["palette", "player"]);
await Promise.resolve(); // let the palette's own reclaim reconcile run expect(navigate).not.toHaveBeenCalled();
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that
player.unmount(); // …which is what the navigation does to the layer below await settle(); // the reconcile reclaims both entries in one go
await settle(); expect(back).toHaveBeenCalledTimes(2);
expect(back).toHaveBeenCalledTimes(1); // must NOT back over the entry navigate just pushed // 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 { } finally {
back.mockRestore(); back.mockRestore();
} }
}); });
// The mirror of the case above, and the OTHER door into `buryUnder` — round 3 found that only // The old helper ran after "a popstate" OR a 250 ms fallback, whichever came first — so a reclaim
// `navigateOverOverlays` was pinned, so this call site could be reverted to the old unconditional // slower than the fallback undid the navigation. It must wait for the history, not the clock.
// burial with the whole suite green. Reachable through the palette: `run: () => setPage(page)` is it("waits for a back() still in flight instead of pushing under it", async () => {
// a bare arrow, so `setPage`'s `false` really does arrive here when the chosen module is the one let land: (() => void) | undefined;
// already on screen. Nothing was pushed, so the layer underneath is still ours to reclaim.
it("leaves the layer below reclaimable when the command navigated nowhere", async () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => { const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); land = () =>
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
}); });
try { try {
const player = renderHook(() => useBackToClose(() => {})); mountLayer(() => {});
await settle(); await settle();
const palette = renderHook(() => useBackToClose(() => {})); const navigate = vi.fn();
act(() => closeOverlaysThenNavigate(navigate));
await settle(); await settle();
expect(back).toHaveBeenCalledTimes(1);
const navigate = vi.fn(() => false); // setPage's early return: already on that page
await act(async () => { await act(async () => {
closeThenNavigate(() => palette.unmount(), navigate); await new Promise((r) => setTimeout(r, 300)); // past where the old fallback fired
await Promise.resolve();
}); });
expect(navigate).toHaveBeenCalledTimes(1); expect(navigate).not.toHaveBeenCalled();
expect(back).toHaveBeenCalledTimes(1); // the palette's own entry
player.unmount(); act(() => land?.());
await settle(); await tick();
expect(back).toHaveBeenCalledTimes(2); // and the player's, because nothing was pushed over it expect(navigate).toHaveBeenCalledTimes(1);
} finally { } finally {
back.mockRestore(); back.mockRestore();
} }
}); });
// Without this the command is not late, it never happens: an overlay closed in the same tick it // No such overlay exists today, but a future "discard your changes?" guard must not have the page
// opened never pushed an entry, so no reclaim popstate is ever coming. // switched out from under it: dropping the command is the honest outcome.
it("navigates anyway when no reclaim popstate ever arrives", () => { it("drops the command when an overlay does not actually close", async () => {
const back = stubBack();
const stubborn = renderHook(() => useBackToClose(() => {}));
await settle();
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const navigate = vi.fn(); const navigate = vi.fn();
closeThenNavigate(() => {}, navigate); act(() => closeOverlaysThenNavigate(navigate));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(navigate).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(navigate).toHaveBeenCalledTimes(1);
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
stubborn.unmount();
await settle();
expect(back).toHaveBeenCalledTimes(1); // its entry is still reclaimed on the way out
back.mockRestore();
} }
}); });
});
describe("navigateOverOverlays", () => { // Shift+W pressed on Home with the player open: setPage finds nothing to push. The player must
// The global-shortcut half of the same trap `closeThenNavigate` solves. Shift+W does not know an // still close (owner decision 2026-09-09), and its entry must be reclaimed rather than orphaned —
// overlay is open and cannot close it — it just navigates, and the page change unmounts the player // an orphan is a later browser Back that changes nothing on screen.
// (which lives inside the module column). That unmount's reconcile then reclaimed the entry the it("closes the overlay and reclaims its entry when the navigation turns out to go nowhere", async () => {
// navigation had only just pushed, so the player closed and the page snapped straight back. const back = stubBack();
it("a navigation made from outside an open overlay survives that overlay's unmount", async () => {
const overlay = renderHook(() => useBackToClose(() => {}));
await settle();
const back = vi.spyOn(window.history, "back");
try { try {
const navigate = vi.fn(() => window.history.pushState({ sfPage: "home" }, "")); const closed = vi.fn();
act(() => navigateOverOverlays(navigate)); mountLayer(closed);
expect(navigate).toHaveBeenCalledTimes(1);
overlay.unmount(); // what the page change does to the player
await settle(); await settle();
// The mark is consumed instead: no back(), so the entry `navigate` pushed is still current. 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(); expect(back).not.toHaveBeenCalled();
} finally { } finally {
back.mockRestore(); 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();
}
});
});
+93 -105
View File
@@ -150,6 +150,15 @@ export function useHistorySubview<T>(
// listener mistook the leaving modal's back() for a genuine user Back and closed itself, and // listener mistook the leaving modal's back() for a genuine user Back and closed itself, and
// (b) a mangled history where the pointer ended up BEHIND the surviving entry, so a later browser // (b) a mangled history where the pointer ended up BEHIND the surviving entry, so a later browser
// Back walked off the app entirely instead of closing the modal. Coalescing removes both. // Back walked off the app entirely instead of closing the modal. Coalescing removes both.
//
// A navigation never lands UNDER an open overlay: `setPage` and `openChannel` close every overlay
// first and push only once their entries are back (`closeOverlaysThenNavigate`). The design before
// E11 let the push land above the overlays and "buried" their entries with a counter, and that
// counter was a whole family of bugs — a burial spent by a different overlay closing first, a
// navigation that could not report whether it pushed and was assumed to have, one browser Back that
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
// last two measured in Chrome, E11 S1). With nothing ever buried, the stack and the history agree by
// construction.
type Overlay = { token: number; cb: () => void }; type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = []; const overlayStack: Overlay[] = [];
let overlayCounter = 0; let overlayCounter = 0;
@@ -157,64 +166,25 @@ let overlayCounter = 0;
let historyDepth = 0; let historyDepth = 0;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored. // Programmatic history.back() calls still awaiting their popstate — each must be ignored.
let pendingProgrammatic = 0; let pendingProgrammatic = 0;
// Overlay entries a navigation has PUSHED PAST. They are still real history entries and their
// overlays may still be mounted, but they are no longer on top, so reclaiming one with history.back()
// would pop the navigation instead. Reconcile consumes them silently. See closeThenNavigate.
let buriedEntries = 0;
let reconcileScheduled = false; let reconcileScheduled = false;
let listenerInstalled = false; let listenerInstalled = false;
// Re-checked after every reconcile — see `afterClosing`.
/** Run a navigation and mark the live overlay entries buried under it — but ONLY if it really const syncWaiters = new Set<() => void>();
* pushed an entry. A navigation that reports `false` pushed nothing, so its overlays are still on
* top and must stay reclaimable: `setPage` early-returns when the requested page is already
* current, and Home is the default landing page, so "Shift+W while already on Home with the player
* open" navigated nowhere yet still buried the player's entry. The player's unmount then consumed
* the mark instead of issuing history.back(), orphaning its browser entry — and the next Back
* popped onto an identical `sfPage` with an empty overlay stack, i.e. a press that did nothing
* (round 2). One rule for both entry points below — but read the next paragraph before trusting it
* at the `closeThenNavigate` one, where most callers cannot report at all.
*
* It fails OPEN: only a literal `false` skips the mark, so a `navigate` that reports nothing is
* assumed to have pushed. That is what the code did unconditionally before, so nothing regressed by
* adding the rule — it just does not yet reach everywhere. Today only `navigateOverOverlays`
* (App's Shift+W → `setPage`) is fully covered. Of `closeThenNavigate`'s callers, only a palette
* row written as a bare `() => setPage(page)` forwards the boolean: `CommandItem.run` is typed
* `() => void`, so the block-bodied and non-navigating rows report nothing — and neither does
* `openChannel`, which has its OWN no-push branch (it `replaceState`s when the entry already
* carries `_chan`) and is reached both from a palette row and from PlayerModal's channel-name jump.
* Each of those still marks a live sibling overlay's entry buried under a push that never happened,
* costing one dead browser-Back press when that overlay is closed by its own control.
*
* Closing it is three changes, not one — widen `CommandItem.run` to `() => boolean | void`, return
* the verdict from the block-bodied rows, and give `openChannel` the boolean `setPage` now has — so
* it is deferred to the project backlog rather than bolted on here.
*
* Marking AFTER the call is safe: `navigate()` is synchronous and only `reconcileHistory` (a
* microtask) and the popstate listener ever move `historyDepth`. */
function buryUnder(navigate: () => boolean | void): void {
if (navigate() !== false) buriedEntries = historyDepth;
}
// Push/pop history entries until their count matches the live overlay count. Runs once per tick. // Push/pop history entries until their count matches the live overlay count. Runs once per tick.
function reconcileHistory(): void { function reconcileHistory(): void {
reconcileScheduled = false; reconcileScheduled = false;
const want = overlayStack.length; const want = overlayStack.length;
// A genuine user Back decrements historyDepth on its own, so a burial can outlive the entries it
// described; never claim more buried entries than there are entries.
if (buriedEntries > historyDepth) buriedEntries = historyDepth;
while (historyDepth < want) { while (historyDepth < want) {
historyDepth++; historyDepth++;
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, ""); window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
} }
while (historyDepth > want && buriedEntries > 0) {
historyDepth--; // buried: the browser entry stays, but it is not ours to reclaim
buriedEntries--;
}
while (historyDepth > want) { while (historyDepth > want) {
historyDepth--; historyDepth--;
pendingProgrammatic++; pendingProgrammatic++;
window.history.back(); window.history.back();
} }
syncWaiters.forEach((cb) => cb());
} }
function scheduleReconcile(): void { function scheduleReconcile(): void {
@@ -240,74 +210,92 @@ function ensureOverlayListener(): void {
}); });
} }
/** Close an overlay that holds a `useBackToClose` entry and THEN navigate — in that order, waiting /** The history holds exactly one entry per live overlay and no programmatic back() is still in
* for the browser to confirm it. * flight — so a pushState made now cannot be undone by one. */
* function historyInSync(): boolean {
* The trap: closing unmounts the overlay, whose cleanup reconciles the history by issuing a return historyDepth === overlayStack.length && pendingProgrammatic === 0 && !reconcileScheduled;
* programmatic `history.back()`. A `pushState` made before that lands (i.e. anything `setPage` /
* `openChannel` does synchronously) is simply undone by it, so the navigation silently does
* nothing. PlayerModal hit this first — clicking a video's channel name only closed the player —
* and the command palette hit it again the day it was written, which is why the fix lives here
* instead of being re-derived at each call site.
*
* Only call this from INSIDE an overlay that uses `useBackToClose`: the popstate we wait for is
* that overlay's own entry being reclaimed, and without one it never arrives. */
export function closeThenNavigate(close: () => void, navigate: () => boolean | void): void {
let done = false;
// Armed BEFORE the close, so it is a plain const and there is no declaration order to reason about.
// Degrades honestly if no popstate is coming at all — an overlay closed in the same tick it opened
// never pushed an entry, so there is nothing to reclaim and the command would hang forever.
// Navigating a beat late beats a control that silently does nothing.
const fallback = setTimeout(() => run(), 250);
const run = () => {
if (done) return;
done = true;
window.removeEventListener("popstate", onPop);
clearTimeout(fallback);
// Every overlay entry still counted is now BURIED under the entry the navigation just pushed, so
// a later unmount must NOT reclaim it with history.back() — that pops the navigation instead and
// silently bounces the user back where they started (a palette opened over the player: closing
// the palette landed its popstate, the navigation unmounted the player too, and the player's
// reconcile ate the new page entry). MARK them rather than zeroing the depth: round 1 set
// `historyDepth = 0` outright, which also desynced the counter from overlays the navigation does
// NOT unmount — About/ReleaseNotes live outside the page column — so one Back both navigated and
// closed them (round 2). `buryUnder` skips the mark when the command reports it navigated
// nowhere — which here is only the palette's bare `() => setPage(page)` module rows; see the
// fail-open limit documented on `buryUnder` for the rows that cannot report.
buryUnder(navigate);
};
const onPop = () => {
// The same reconcile can issue MORE than one back(); a pushState made between them would be
// popped by the next. Wait for the last one to land.
if (pendingProgrammatic > 0) return;
run();
};
window.addEventListener("popstate", onPop);
close();
} }
/** Navigate from OUTSIDE any overlay — a global keyboard shortcut — when one may be open. // How long an overlay gets to actually close before its command is dropped. Closing is one React
// render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today,
// but a future "discard your changes?" guard must not be navigated out from under.
const CLOSE_TIMEOUT_MS = 1500;
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
* *
* `closeThenNavigate` is for a control that lives INSIDE the overlay and can close it, and it waits * The trap: closing unmounts an overlay, whose cleanup reconciles by issuing a programmatic
* for that overlay's own entry to come back. A global key has neither: it does not know which * `history.back()`. A pushState made before that lands is undone by it, so a command that navigates
* overlay is open, or whether one is at all. It just navigates, and the page change unmounts * silently does nothing but close. PlayerModal hit it first; the command palette the day it was
* whatever the module column was holding — whose reconcile then reclaims the entry the navigation * written.
* had just pushed. Shift+W over an open player closed the player and snapped back to the page it
* started on, which reads as a dead shortcut.
* *
* Same fix as `closeThenNavigate`, one step shorter: bury the live overlay entries FIRST, so a * It waits for a STATE, not an event. The helper before E11 waited for "a popstate" plus a 250 ms
* later unmount consumes the marks instead of issuing `history.back()`. There is no popstate to * fallback — but one reconcile can issue several back()s, an overlay closed in the tick it opened
* wait for, because nothing is being closed on purpose. An overlay the navigation does NOT unmount * issues none, and the fallback then ran with back()s still in flight. Here the reconcile and every
* (the palette and the cheat sheet live above the page column) keeps its entry buried too — the * popstate re-check one predicate.
* same trade `closeThenNavigate` already makes, and the reason closing the palette afterwards no
* longer pops the page out from under the user.
* *
* `navigate` reports whether it actually pushed — see `buryUnder`, which is why `setPage` returns a * `run` goes out on a fresh task, never inside the popstate dispatch that settled it: listeners
* boolean rather than this guessing from `history.length` (a `back()` followed by a `pushState` * registered before ours (NavigationProvider's) have read that event already, but ones after it
* leaves it unchanged) or from the entry's `sfScroll` id (`openChannel` pushes without minting * have not, and a page applied from the OLD entry after our push would undo the navigation. */
* one). */ function afterClosing(targets: readonly Overlay[], close: () => void, run: () => void): void {
export function navigateOverOverlays(navigate: () => boolean | void): void { let done = false;
buryUnder(navigate); let queued = false;
const gone = () => targets.every((o) => !overlayStack.includes(o));
const ready = () => !done && gone() && historyInSync();
const stop = () => {
done = true;
syncWaiters.delete(check);
window.removeEventListener("popstate", check);
clearTimeout(timeout);
};
function check(): void {
if (queued || !ready()) return;
queued = true;
setTimeout(() => {
queued = false;
if (!ready()) return;
stop();
run();
}, 0);
}
const timeout = setTimeout(() => {
if (done) return;
stop();
// Still mounted: the overlay refused to close, so drop the command rather than navigate under
// it. Gone but out of sync: a back() never reported back — run late rather than never.
if (gone()) run();
}, CLOSE_TIMEOUT_MS);
syncWaiters.add(check);
window.addEventListener("popstate", check);
close();
check();
}
/** Navigate over whatever overlays are open: close them all (topmost first, the order Back peels
* them in), then navigate once their entries are back. Synchronous when nothing is open — the
* ordinary rail click.
*
* `setPage` and `openChannel` call it themselves, so no call site can forget it: a global shortcut,
* a toast's "View" clicked inside the player, a palette row, the player's channel link. Closing is
* what a page change already did to the module-owned overlays (it unmounts them); for the ones above
* the page column (About, the cheat sheet, the palette) it means the destination is what the user
* sees, not a dialog still standing over it. A navigation that turns out to go nowhere — Shift+W on
* Home with the player open — still closes, so the key always ends with the answer on screen (owner
* decision 2026-09-09). */
export function closeOverlaysThenNavigate(navigate: () => void): void {
if (overlayStack.length === 0 && historyInSync()) {
navigate();
return;
}
const targets = [...overlayStack];
afterClosing(targets, () => [...targets].reverse().forEach((o) => o.cb()), navigate);
}
/** Close the overlay a control lives in, then run its command once that overlay's entry is back —
* the command palette's rows. Overlays underneath are left alone: a theme picked over the player
* must not close the player, and a row that navigates closes it through `setPage` anyway. */
export function closeTopOverlayThenRun(close: () => void, run: () => void): void {
const top = overlayStack[overlayStack.length - 1];
afterClosing(top ? [top] : [], close, run);
} }
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via /** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
-40
View File
@@ -1,40 +0,0 @@
// @vitest-environment jsdom
//
// `navigateTo` is what a toast's action calls ("Download added — View"), and that click happens
// INSIDE an overlay's toast host. The page change unmounts the page-owned overlay (the player), whose
// Back-to-close cleanup then reclaimed the history entry the navigation had just pushed — so the user
// landed back where they started with the player closed instead of on the Downloads page
// (deep-review round 4). Same trap, and the same fix, as the global Shift+W shortcut.
//
// Its own file: `lib/history`'s depth bookkeeping is module state.
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useBackToClose } from "./history";
import { navigateTo, setNavigator } from "./nav";
const settle = () =>
act(async () => {
await Promise.resolve();
});
describe("navigateTo from inside an overlay", () => {
it("buries the overlay's entry, so closing it does not pop the page just opened", async () => {
const overlay = renderHook(() => useBackToClose(() => {}));
await settle();
const back = vi.spyOn(window.history, "back");
try {
setNavigator(() => {
window.history.pushState({ sfPage: "downloads" }, "");
return true; // as App's setPage reports: it really pushed
});
act(() => navigateTo("downloads"));
overlay.unmount(); // the page change unmounts the player
await settle();
expect(back).not.toHaveBeenCalled();
expect(window.history.state).toMatchObject({ sfPage: "downloads" });
} finally {
back.mockRestore();
setNavigator(() => {});
}
});
});
+9 -10
View File
@@ -1,21 +1,20 @@
import { navigateOverOverlays } from "./history";
import type { Page } from "./urlState"; import type { Page } from "./urlState";
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download // Tiny decoupled navigator so deep components (a feed card's download toast, the Download
// Center) can switch pages without threading setPage through the whole tree. App registers // Center) can switch pages without threading setPage through the whole tree. NavigationProvider
// its setPage on mount; navigateTo is a no-op until then. // registers its setPage on mount; navigateTo is a no-op until then.
let _navigate: ((p: Page) => boolean | void) | null = null; let _navigate: ((p: Page) => void) | null = null;
export function setNavigator(fn: (p: Page) => boolean | void): void { export function setNavigator(fn: (p: Page) => void): void {
_navigate = fn; _navigate = fn;
} }
/** Over whatever overlay is open: the "View" on a "Download added" toast is clicked INSIDE the player /** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is
* (its toast host), and the page change unmounts the page-owned player, whose Back-to-close cleanup * safe because `setPage` closes every open overlay before it pushes (see
* then popped the entry the navigation had just pushed — the user landed back on the feed with the * `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the
* player gone (deep-review round 4). Same trap, same fix as Shift+W (see `navigateOverOverlays`). */ * entry the navigation had just pushed (deep-review round 4). */
export function navigateTo(p: Page): void { export function navigateTo(p: Page): void {
navigateOverOverlays(() => _navigate?.(p)); _navigate?.(p);
} }
/** Should this click be handled IN-APP, or left to the browser? /** Should this click be handled IN-APP, or left to the browser?