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:
@@ -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
@@ -37,7 +37,6 @@ import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { navigateOverOverlays } from "./lib/history";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
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
|
||||
// (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.
|
||||
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
|
||||
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
|
||||
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
|
||||
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
|
||||
// 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")));
|
||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
useGlobalKeymap(!!meQuery.data, () => setPage("home"));
|
||||
|
||||
// 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
|
||||
@@ -279,8 +275,8 @@ export default function App() {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav).
|
||||
navigateOverOverlays(() => setPage("users"));
|
||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||
setPage("users");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// Back-to-close history entry; see closeThenNavigate).
|
||||
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||
channelId: string;
|
||||
title: string | null | undefined;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
|
||||
// before this. `setPage` early-returns when the requested page is already current with no channel
|
||||
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
|
||||
// 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 navigation actions against the overlay history stack: `setPage` and `openChannel` never push
|
||||
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
|
||||
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
|
||||
//
|
||||
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
|
||||
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
|
||||
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
|
||||
// early return skips, which is what the boolean actually stands for.
|
||||
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
|
||||
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
|
||||
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
|
||||
// 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 { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
const render = () =>
|
||||
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(() => {
|
||||
localStorage.clear();
|
||||
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
|
||||
// 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", () => {
|
||||
it("returns true and pushes an entry for a real page change", () => {
|
||||
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
it("pushes an entry for a real page change", () => {
|
||||
const { result } = render();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage("plex");
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
});
|
||||
|
||||
it("returns false and pushes nothing when it is already that page", () => {
|
||||
it("pushes nothing when it is already that page", () => {
|
||||
const { result } = render();
|
||||
act(() => {
|
||||
result.current.setPage("plex");
|
||||
});
|
||||
act(() => result.current.setPage("plex"));
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
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();
|
||||
}
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
||||
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.
|
||||
const current = result.current.page;
|
||||
expect(current).toBe("home");
|
||||
act(() => {
|
||||
result.current.openChannel("UC1", "A channel");
|
||||
});
|
||||
act(() => result.current.openChannel("UC1", "A channel"));
|
||||
expect(result.current.channelView).not.toBeNull();
|
||||
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage(current);
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toBeNull();
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
act(() => result.current.setPage(current));
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · never under an open overlay", () => {
|
||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||
const { result } = render();
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
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";
|
||||
|
||||
// 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 {
|
||||
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
|
||||
* is already current with no channel page over it, and a caller that buries overlay history
|
||||
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
|
||||
setPage: (next: Page) => boolean;
|
||||
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
|
||||
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
|
||||
* after the call; with nothing open it is synchronous. The requested page being already current
|
||||
* (with no channel page over it) pushes nothing — but the overlays still close. */
|
||||
setPage: (next: Page) => void;
|
||||
openChannel: (id: string, name?: string) => void;
|
||||
closeChannel: () => void;
|
||||
enterYtSearch: (q: string) => void;
|
||||
@@ -53,7 +54,7 @@ const StateContext = createContext<NavigationState>({
|
||||
ytSearch: null,
|
||||
});
|
||||
const ActionsContext = createContext<NavigationActions>({
|
||||
setPage: () => false,
|
||||
setPage: () => {},
|
||||
openChannel: () => {},
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
@@ -118,53 +119,61 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page) => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return false;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page 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 their page.
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
return true;
|
||||
},
|
||||
(next: Page) =>
|
||||
// Never under an open overlay — see the interface doc. The refs are read when the push
|
||||
// actually happens, which over an open player is a moment after this call.
|
||||
closeOverlaysThenNavigate(() => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
||||
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
||||
// 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
|
||||
// their page.
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
}),
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
// 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
|
||||
// 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) => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
const url = urlWithParams({ chan: id });
|
||||
if (st._chan)
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
closeOverlaysThenNavigate(() => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
const url = urlWithParams({ chan: id });
|
||||
if (st._chan)
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeChannel = useCallback(() => {
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
import { afterRemoval } from "../lib/playerQueue";
|
||||
@@ -822,13 +822,11 @@ export default function PlayerModal({
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Close first, open the channel only once this player's own Back-to-close
|
||||
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
||||
// clicking the name merely closes the player. The rule lives in history.ts
|
||||
// now; the command palette needs exactly the same one.
|
||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||
// openChannel closes this player first and pushes only once its
|
||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||
// and clicking the name merely closes the player
|
||||
// (lib/history.closeOverlaysThenNavigate).
|
||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Component-test lane pilot: the useBackToExit "back-ladder". This is the first jsdom/renderHook
|
||||
// test in the repo (the pure-node lane can't touch window.history / popstate). It exercises the
|
||||
// module-level overlay stack in history.ts through its only observable effect — which onExit fires.
|
||||
// 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 in
|
||||
// history.ts 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 without
|
||||
// depending on jsdom's async back() semantics. The invariant that keeps the module-global
|
||||
// bookkeeping clean between tests here is NOT "only genuine Backs" — two closeThenNavigate cases
|
||||
// deliberately drive a reconcile into history.back() — it is that no REAL back() is ever left in
|
||||
// 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).
|
||||
// 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 { closeThenNavigate, navigateOverOverlays, useBackToClose, useBackToExit } from "./history";
|
||||
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();
|
||||
@@ -92,137 +115,179 @@ describe("useBackToExit back-ladder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeThenNavigate", () => {
|
||||
// The bug it exists for: an overlay's unmount reclaims its Back-to-close entry with a programmatic
|
||||
// history.back(). A pushState made before that lands is undone by it, so a dialog command that
|
||||
// navigates does nothing but close. PlayerModal hit it once; the command palette hit it again.
|
||||
it("closes first and navigates only after the reclaim lands", () => {
|
||||
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", () => {
|
||||
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();
|
||||
closeThenNavigate(() => {}, navigate);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
closeOverlaysThenNavigate(navigate);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// E10 review round 1. Waiting for ONE reclaim is not enough when a SECOND Back-closable layer is
|
||||
// still mounted and the navigation is what unmounts it: that layer's own reconcile fired another
|
||||
// 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 }));
|
||||
});
|
||||
it("closes every open overlay, topmost first, and navigates only once all their entries are back", async () => {
|
||||
const back = stubBack();
|
||||
try {
|
||||
const player = renderHook(() => useBackToClose(() => {}));
|
||||
const closed: string[] = [];
|
||||
mountLayer(() => closed.push("player"));
|
||||
await settle();
|
||||
const palette = renderHook(() => useBackToClose(() => {}));
|
||||
mountLayer(() => closed.push("palette"));
|
||||
await settle();
|
||||
expect(back).not.toHaveBeenCalled(); // both layers hold an entry; nothing reclaimed yet
|
||||
|
||||
const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, ""));
|
||||
await act(async () => {
|
||||
closeThenNavigate(() => palette.unmount(), navigate);
|
||||
await Promise.resolve(); // let the palette's own reclaim reconcile run
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that
|
||||
act(() => closeOverlaysThenNavigate(navigate));
|
||||
expect(closed).toEqual(["palette", "player"]);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
player.unmount(); // …which is what the navigation does to the layer below
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1); // must NOT back over the entry navigate just pushed
|
||||
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 mirror of the case above, and the OTHER door into `buryUnder` — round 3 found that only
|
||||
// `navigateOverOverlays` was pinned, so this call site could be reverted to the old unconditional
|
||||
// burial with the whole suite green. Reachable through the palette: `run: () => setPage(page)` is
|
||||
// a bare arrow, so `setPage`'s `false` really does arrive here when the chosen module is the one
|
||||
// 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 () => {
|
||||
// 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(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
land = () =>
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
try {
|
||||
const player = renderHook(() => useBackToClose(() => {}));
|
||||
mountLayer(() => {});
|
||||
await settle();
|
||||
const palette = renderHook(() => useBackToClose(() => {}));
|
||||
const navigate = vi.fn();
|
||||
act(() => closeOverlaysThenNavigate(navigate));
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
|
||||
const navigate = vi.fn(() => false); // setPage's early return: already on that page
|
||||
await act(async () => {
|
||||
closeThenNavigate(() => palette.unmount(), navigate);
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setTimeout(r, 300)); // past where the old fallback fired
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(back).toHaveBeenCalledTimes(1); // the palette's own entry
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
player.unmount();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(2); // and the player's, because nothing was pushed over it
|
||||
act(() => land?.());
|
||||
await tick();
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
back.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Without this the command is not late, it never happens: an overlay closed in the same tick it
|
||||
// opened never pushed an entry, so no reclaim popstate is ever coming.
|
||||
it("navigates anyway when no reclaim popstate ever arrives", () => {
|
||||
// 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();
|
||||
closeThenNavigate(() => {}, navigate);
|
||||
act(() => closeOverlaysThenNavigate(navigate));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
stubborn.unmount();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1); // its entry is still reclaimed on the way out
|
||||
back.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigateOverOverlays", () => {
|
||||
// The global-shortcut half of the same trap `closeThenNavigate` solves. Shift+W does not know an
|
||||
// overlay is open and cannot close it — it just navigates, and the page change unmounts the player
|
||||
// (which lives inside the module column). That unmount's reconcile then reclaimed the entry the
|
||||
// navigation had only just pushed, so the player closed and the page snapped straight back.
|
||||
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");
|
||||
// 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 navigate = vi.fn(() => window.history.pushState({ sfPage: "home" }, ""));
|
||||
act(() => navigateOverOverlays(navigate));
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
overlay.unmount(); // what the page change does to the player
|
||||
const closed = vi.fn();
|
||||
mountLayer(closed);
|
||||
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();
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+93
-105
@@ -150,6 +150,15 @@ export function useHistorySubview<T>(
|
||||
// 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
|
||||
// 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 };
|
||||
const overlayStack: Overlay[] = [];
|
||||
let overlayCounter = 0;
|
||||
@@ -157,64 +166,25 @@ let overlayCounter = 0;
|
||||
let historyDepth = 0;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
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 listenerInstalled = false;
|
||||
|
||||
/** Run a navigation and mark the live overlay entries buried under it — but ONLY if it really
|
||||
* 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;
|
||||
}
|
||||
// Re-checked after every reconcile — see `afterClosing`.
|
||||
const syncWaiters = new Set<() => void>();
|
||||
|
||||
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
|
||||
function reconcileHistory(): void {
|
||||
reconcileScheduled = false;
|
||||
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) {
|
||||
historyDepth++;
|
||||
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) {
|
||||
historyDepth--;
|
||||
pendingProgrammatic++;
|
||||
window.history.back();
|
||||
}
|
||||
syncWaiters.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
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
|
||||
* for the browser to confirm it.
|
||||
*
|
||||
* The trap: closing unmounts the overlay, whose cleanup reconciles the history by issuing a
|
||||
* 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();
|
||||
/** The history holds exactly one entry per live overlay and no programmatic back() is still in
|
||||
* flight — so a pushState made now cannot be undone by one. */
|
||||
function historyInSync(): boolean {
|
||||
return historyDepth === overlayStack.length && pendingProgrammatic === 0 && !reconcileScheduled;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* for that overlay's own entry to come back. A global key has neither: it does not know which
|
||||
* overlay is open, or whether one is at all. It just navigates, and the page change unmounts
|
||||
* whatever the module column was holding — whose reconcile then reclaims the entry the navigation
|
||||
* 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.
|
||||
* The trap: closing unmounts an overlay, whose cleanup reconciles by issuing a programmatic
|
||||
* `history.back()`. A pushState made before that lands is undone by it, so a command that navigates
|
||||
* silently does nothing but close. PlayerModal hit it first; the command palette the day it was
|
||||
* written.
|
||||
*
|
||||
* Same fix as `closeThenNavigate`, one step shorter: bury the live overlay entries FIRST, so a
|
||||
* later unmount consumes the marks instead of issuing `history.back()`. There is no popstate to
|
||||
* wait for, because nothing is being closed on purpose. An overlay the navigation does NOT unmount
|
||||
* (the palette and the cheat sheet live above the page column) keeps its entry buried too — the
|
||||
* same trade `closeThenNavigate` already makes, and the reason closing the palette afterwards no
|
||||
* longer pops the page out from under the user.
|
||||
* It waits for a STATE, not an event. The helper before E11 waited for "a popstate" plus a 250 ms
|
||||
* fallback — but one reconcile can issue several back()s, an overlay closed in the tick it opened
|
||||
* issues none, and the fallback then ran with back()s still in flight. Here the reconcile and every
|
||||
* popstate re-check one predicate.
|
||||
*
|
||||
* `navigate` reports whether it actually pushed — see `buryUnder`, which is why `setPage` returns a
|
||||
* boolean rather than this guessing from `history.length` (a `back()` followed by a `pushState`
|
||||
* leaves it unchanged) or from the entry's `sfScroll` id (`openChannel` pushes without minting
|
||||
* one). */
|
||||
export function navigateOverOverlays(navigate: () => boolean | void): void {
|
||||
buryUnder(navigate);
|
||||
* `run` goes out on a fresh task, never inside the popstate dispatch that settled it: listeners
|
||||
* registered before ours (NavigationProvider's) have read that event already, but ones after it
|
||||
* have not, and a page applied from the OLD entry after our push would undo the navigation. */
|
||||
function afterClosing(targets: readonly Overlay[], close: () => void, run: () => void): void {
|
||||
let done = false;
|
||||
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
|
||||
|
||||
@@ -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
@@ -1,21 +1,20 @@
|
||||
import { navigateOverOverlays } from "./history";
|
||||
import type { Page } from "./urlState";
|
||||
|
||||
// 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
|
||||
// its setPage on mount; navigateTo is a no-op until then.
|
||||
let _navigate: ((p: Page) => boolean | void) | null = null;
|
||||
// Center) can switch pages without threading setPage through the whole tree. NavigationProvider
|
||||
// registers its setPage on mount; navigateTo is a no-op until then.
|
||||
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;
|
||||
}
|
||||
|
||||
/** Over whatever overlay is open: the "View" on a "Download added" toast is clicked INSIDE the player
|
||||
* (its toast host), and the page change unmounts the page-owned player, whose Back-to-close cleanup
|
||||
* then popped the entry the navigation had just pushed — the user landed back on the feed with the
|
||||
* player gone (deep-review round 4). Same trap, same fix as Shift+W (see `navigateOverOverlays`). */
|
||||
/** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is
|
||||
* safe because `setPage` closes every open overlay before it pushes (see
|
||||
* `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the
|
||||
* entry the navigation had just pushed (deep-review round 4). */
|
||||
export function navigateTo(p: Page): void {
|
||||
navigateOverOverlays(() => _navigate?.(p));
|
||||
_navigate?.(p);
|
||||
}
|
||||
|
||||
/** Should this click be handled IN-APP, or left to the browser?
|
||||
|
||||
Reference in New Issue
Block a user