Files
siftlode/frontend/src/lib/history.ts
T
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00

593 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// In-app browser-history integration so Back/Forward step through a module's sub-views and
// overlays before leaving the page — not straight back to the previous module.
//
// Two primitives share the existing page-history (App stamps `sfPage` in history.state):
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath. An overlay
// that is part of the view (a player) names itself in its entry's address.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
import { useLatest } from "./useLatest";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
// remembers scroll offsets per (entry id + view key) so Back restores exactly where the user left
// an entry, while a forward navigation (a new id) starts at the top. We drive scroll ourselves, so
// the browser's own window-scroll restoration (which can't see our inner <main> scroller) is off.
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
let navCounter = 0;
// sfScroll ids are persisted into window.history.state and survive a full page reload, but this
// module-level counter resets to 0 on every load. Seed it past the current entry's id so freshly
// minted ids can't collide with ids already carried by pre-reload back-stack entries (ids are
// assigned in forward order, so the current entry's id is ≥ every ancestor's) — a collision would
// otherwise cross-contaminate PageScroller's per-entry scroll offsets after a reload + navigation.
if (typeof window !== "undefined") {
const seed = (window.history.state as { sfScroll?: number } | null)?.sfScroll;
if (typeof seed === "number") navCounter = seed;
}
/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */
export function nextNavId(): number {
return ++navCounter;
}
/** The current entry's scroll id, assigning one (via replaceState) to entries that predate this — the
* initial load, or an entry pushed without one — so the id survives Back/Forward. */
export function ensureNavId(): number {
const st = (window.history.state ?? {}) as { sfScroll?: number };
if (typeof st.sfScroll === "number") {
// Keep the counter ahead of any id we observe (e.g. a Back landed on a higher-id entry after a
// reload seeded us low) so the next mint never duplicates a live entry's id.
if (st.sfScroll > navCounter) navCounter = st.sfScroll;
return st.sfScroll;
}
const id = nextNavId();
window.history.replaceState({ ...st, sfScroll: id }, "");
return id;
}
/** Read the current entry's scroll id without assigning one (0 if none). */
export function peekNavId(): number {
const st = window.history.state as { sfScroll?: number } | null;
return typeof st?.sfScroll === "number" ? st.sfScroll : 0;
}
const navListeners = new Set<() => void>();
/** Fire after a pushState that opens a new view but does NOT re-render PageScroller (a Plex sub-view
* open): lets the scroller run its restore/reset without waiting on a React render it won't get. */
export function notifyNav(): void {
navListeners.forEach((cb) => cb());
}
export function subscribeNav(cb: () => void): () => void {
navListeners.add(cb);
return () => navListeners.delete(cb);
}
// --- The list item a view was left from (history.state.sfNav) -------------------------------------
// See lib/listNav "The item a view was left from". Stamped onto the entry being LEFT, right before a
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
// Installed at load, not by the first push: a stamp survives a reload in history.state, and the first
// Back after one must still find a listener.
let cancelNavRestore = () => {};
if (typeof window !== "undefined") {
window.addEventListener("popstate", (e) => {
cancelNavRestore();
const pos = (e.state as { sfNav?: NavPosition } | null)?.sfNav;
if (!pos) return;
const { sfNav: _spent, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
cancelNavRestore = restoreNavPosition(pos);
});
}
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
* must spread this, so the new entry does not inherit a position it was never left from — nor an
* overlay's marks, which belong to the entry being left (an entry a reload under an open dialog
* started from carries them, and the first overlay over a view pushed from it took that view's entry
* over for its own). */
export function rememberNavPosition(): Record<string, unknown> {
// A restore still waiting for the last landing's lists belongs to the page being left: left
// running, it would focus "list N, item M" of whatever page this push opens.
cancelNavRestore();
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
const pos = navPosition();
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
const { _ov: _overlay, _ovd: _depth, ...view } = st;
return view;
}
// --- Is the entry a back() lands on still ours? --------------------------------------------
// Chrome keeps 50 entries a tab. When the list is full a new entry drops the OLDEST "skippable" one —
// an entry the page left by a pushState without a user gesture (an auto-skip countdown, a card that
// opened on its own) — not the oldest entry, so it can be this page's own entry under a sub-view.
// A programmatic back() then leaves the document for whatever older page sits below (measured
// 2026-10-07: an older entry whose restored Plex player started playing on its own). The Navigation
// API says which document the entry a back() lands on belongs to — counting the back()s of ours still
// in flight; a browser without it keeps the old assumption that our entries are all still there.
interface NavigationLike {
currentEntry: { index: number } | null;
entries: () => readonly { sameDocument: boolean }[];
}
/** Would `steps` more steps back (one, a history.back()) still land in this document? */
export function backStaysInDocument(steps = 1): boolean {
const nav = (window as { navigation?: NavigationLike }).navigation;
const at = nav?.currentEntry?.index;
if (nav == null || at == null) return true;
return nav.entries()[at - pendingSteps - steps]?.sameDocument === true;
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
export interface SubviewUrl<T> {
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
key: string;
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
encode: (view: T) => string | null;
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
decode: (raw: string) => T | null;
/** What the history ENTRY keeps of a view, when that is less than the view opens with: a one-shot
* flag (the Plex player's `fromStart`) must not replay on a reload or a Forward onto the entry. */
entry?: (view: T) => T;
}
export function useHistorySubview<T>(
root: T,
url?: SubviewUrl<T>,
): {
view: T;
/** `replace` rewrites the CURRENT entry instead of stacking a new one — for a sub-view that is the
* continuation of the navigation that just arrived here, not a step the user took inside the
* module. See the `jumpTo` hand-off in PlexBrowse. */
open: (next: T, opts?: { replace?: boolean }) => void;
/** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands
* on says so from now on, without a navigation — the overlays over it stay open. */
follow: (next: T) => void;
back: () => void;
} {
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
const readView = (): T => {
const fromState = window.history.state?._sub as T | undefined;
if (fromState != null) return fromState;
if (url) {
const raw = new URLSearchParams(window.location.search).get(url.key);
const decoded = raw != null ? url.decode(raw) : null;
if (decoded != null) return decoded;
}
return root;
};
const [view, setView] = useState<T>(readView);
// Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its
// own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten:
// the view's own entry under the overlay still says where it was until a step lands back on it.
const followedRef = useRef<{ entry: number; view: T } | null>(null);
const readLandedView = useEffectEvent((): T => {
const followed = followedRef.current;
if (followed == null || peekNavId() !== followed.entry) return readView();
writeSubEntry(followed.view, url);
return followed.view;
});
useEffect(() => {
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
const onPop = () => setView(readLandedView());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// `open` and `back` are memoised so they are REFERENTIALLY stable, not merely behaviourally so.
// They used to be plain inline arrows: correct, but a fresh identity every render, which forced
// every consumer that listens for a sub-view change to disable the dependency check rather than
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
// module functions and the setter, so there is nothing left to churn on.
// Both move the module to another view, so both are navigations: they close the overlays standing
// over it first (`closeOverlaysThenNavigate`, synchronous when none is open). A step taken under an
// open overlay landed as a USER Back — the overlay listener spent it closing the top overlay, so
// leaving the Plex player with its stats readout up only closed the readout — and a push or a
// replace on top of an overlay's entry is undone when that entry is reclaimed.
const go = useCallback(
(next: T, opts?: { replace?: boolean }) => {
setView(next);
// A navigation says where the view is now, on the entry it lands on or rewrites.
followedRef.current = null;
if (opts?.replace) {
writeSubEntry(next, url);
notifyNav();
return;
}
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState(
{ ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() },
"",
href,
);
notifyNav();
},
[url],
);
const open = useCallback(
(next: T, opts?: { replace?: boolean }) => closeOverlaysThenNavigate(() => go(next, opts)),
[go],
);
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
// the root view in place — a step up the module, not out of the app.
const rootRef = useLatest(root);
const back = useCallback(
() =>
closeOverlaysThenNavigate(() => {
if (backStaysInDocument()) window.history.back();
else go(rootRef.current, { replace: true });
}),
[go, rootRef],
);
const follow = useCallback(
(next: T) => {
setView(next);
followedRef.current = { entry: ensureNavId(), view: next };
writeSubEntry(next, url);
},
[url],
);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]);
}
// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys
// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top
// without minting an id that would orphan the one this entry already carries.
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
}
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
// A SINGLE module-level popstate handler owns the whole overlay stack, and history is kept in
// sync LAZILY via a coalesced microtask rather than eagerly per mount/unmount. This matters for
// modal→modal handoffs (e.g. About → Release notes): the leaving modal unmounts and the entering
// one mounts in the SAME React commit, so within one tick the desired overlay-entry count drops
// by one then rises by one — a net zero. Reconciling once, after the tick settles, sees that net
// zero and touches history NOT AT ALL: the new modal simply reuses the old one's entry.
//
// The previous eager design pushed on mount and called history.back() on unmount independently.
// Interleaved during a handoff that produced (a) a flash-and-vanish where the new modal's popstate
// 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`). An earlier design
// 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). With nothing ever buried, the stack and the history agree by
// construction.
// `url`: the keys an overlay names in the address of its own entry (and of the entries above it).
type Overlay = { token: number; cb: () => void; url?: OverlayUrl };
type OverlayUrl = Record<string, string | null>;
const overlayStack: Overlay[] = [];
// Every key an overlay has named in this document: an entry carries those of the overlays it stands
// for and none of the rest. The entry under an overlay is never written with its keys, so Back lands
// on an address without it; and one that still names a closed overlay (the entry a reload under an
// open player started from, a Forward onto an entry whose overlay is gone) is cleared once it is
// the current entry.
const overlayUrlKeys = new Set<string>();
let overlayCounter = 0;
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
const nextOverlayToken = () => ++overlayCounter;
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0;
// The entry this document was loaded on, when it was an overlay's (a reload under an open player):
// the first overlay to open takes it over rather than pushing another over it — pushed, Back would
// close the player onto that same page again, one dead step per reload. Until the first push or Back;
// a view pushed from it does not carry its mark (rememberNavPosition). `_ovd` on an overlay's entry is how many overlay
// entries it stood on, its own included: a reload under two (a dialog, or a maximised player, over
// the player) leaves the lower one's entry under the one taken over, and closing the taken-over
// overlay steps back over those too (`deadBelow`) — one Back closes it, as it opened.
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
const reloadedDepth: unknown = window.history.state?._ovd;
let deadBelow = 0;
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
// many entries they step over in all (a step over dead entries is one traversal of several).
let pendingProgrammatic = 0;
let pendingSteps = 0;
const pendingStepCounts: number[] = [];
function stepBack(steps: number): void {
pendingProgrammatic++;
pendingSteps += steps;
pendingStepCounts.push(steps);
if (steps === 1) window.history.back();
else window.history.go(-steps);
}
function landedStepBack(): void {
pendingProgrammatic--;
pendingSteps -= pendingStepCounts.shift() ?? 1;
}
// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it.
function stepsToClose(): number {
const steps = historyDepth === 0 ? 1 + deadBelow : 1;
if (historyDepth === 0) deadBelow = 0;
return steps;
}
let reconcileScheduled = false;
let listenerInstalled = false;
// 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.
// A push waits while a back() of ours is still in flight: it would land on the entry just pushed and
// take it, leaving the books one entry above the history — and the next close then stepped back over
// the entry BELOW, the player's own (the Plex player's ⋯ → Settings, then a click outside the panel,
// closed the player). The popstate that lands the last back() reconciles again.
function reconcileHistory(): void {
reconcileScheduled = false;
const want = overlayStack.length;
while (historyDepth < want && pendingProgrammatic === 0) {
historyDepth++;
const here = window.history.state?._ov;
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
reloadedOverlayEntry = null;
if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1;
continue;
}
reloadedOverlayEntry = null;
window.history.pushState(
{ ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow },
"",
);
}
while (historyDepth > want) {
historyDepth--;
const steps = stepsToClose();
if (backStaysInDocument(steps)) stepBack(steps);
else if (steps > 1 && backStaysInDocument()) stepBack(1);
else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const {
_ov: _drop,
_ovd: _depth,
...st
} = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
}
}
nameOverlaysHere();
syncWaiters.forEach((cb) => cb());
}
// The address of the entry the open overlays stand on: their keys, every other overlay's cleared.
// Undefined (the address as it is) while no overlay names any. Written by `nameOverlaysHere` alone:
// a pushed entry starts as a copy of the one under it and is named once it is the current one.
function overlayHref(): string | undefined {
if (overlayUrlKeys.size === 0) return undefined;
const updates: OverlayUrl = {};
for (const k of overlayUrlKeys) updates[k] = null;
for (const o of overlayStack) Object.assign(updates, o.url);
return urlWithParams(updates);
}
// Write the current entry's overlay keys, once the history has caught up with the stack (before
// that, the current entry may be one about to be pushed over or stepped back from).
function nameOverlaysHere(): void {
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
const href = overlayHref();
if (href != null && href !== hrefNow())
window.history.replaceState(window.history.state, "", href);
}
const hrefNow = () => window.location.pathname + window.location.search + window.location.hash;
function scheduleReconcile(): void {
if (reconcileScheduled) return;
reconcileScheduled = true;
queueMicrotask(reconcileHistory);
}
function ensureOverlayListener(): void {
if (listenerInstalled) return;
listenerInstalled = true;
window.addEventListener("popstate", () => {
reloadedOverlayEntry = null;
if (pendingProgrammatic > 0) {
landedStepBack(); // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry.
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
else nameOverlaysHere();
return;
}
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
// The last one closing over dead entries (see `deadBelow`) steps over them too.
if (historyDepth > 0) historyDepth--;
const dead = historyDepth === 0 ? deadBelow : 0;
if (historyDepth === 0) deadBelow = 0;
if (dead > 0 && backStaysInDocument(dead)) stepBack(dead);
const top = overlayStack.pop();
if (top) top.cb();
scheduleReconcile();
});
}
/** 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;
}
// 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;
// An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
// call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
// navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
// reads what the last one closed), until the entry is gone or the timeout drops the command.
const RECLOSE_MS = 50;
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
*
* 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.
*
* It waits for a STATE, not an event. An earlier helper 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.
*
* `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,
reclose = false,
): 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);
clearInterval(again);
};
const again = reclose
? setInterval(() => {
if (!done && !gone()) close();
}, RECLOSE_MS)
: undefined;
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];
const close = () =>
[...targets]
.reverse()
.filter((o) => overlayStack.includes(o))
.forEach((o) => o.cb());
afterClosing(targets, close, navigate, true);
}
/** 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
* `onClose` instead of navigating away. Mount the component only while the overlay is open.
* `url`: what the overlay names in its entry's address (a null value: the key absent), followed as
* it changes; the entry under it never carries those keys. */
export function useBackToClose(onClose: () => void, url?: OverlayUrl): void {
const cb = useEffectEvent(onClose);
const named = useEffectEvent(() => url);
const entryRef = useRef<Overlay | null>(null);
useEffect(() => {
ensureOverlayListener();
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
entryRef.current = entry;
overlayStack.push(entry);
scheduleReconcile();
return () => {
entryRef.current = null;
const idx = overlayStack.indexOf(entry);
// idx === -1 means the shared handler already removed us on a real user Back; either way,
// just re-sync history to the (possibly unchanged) overlay count on the next tick.
if (idx !== -1) overlayStack.splice(idx, 1);
scheduleReconcile();
};
}, []);
// After the effect above (declaration order), so the first run names the entry before it is pushed.
const urlDep = url ? JSON.stringify(url) : "";
useEffect(() => {
const entry = entryRef.current;
const next = named();
if (!entry || !next) return;
entry.url = next;
for (const k of Object.keys(next)) overlayUrlKeys.add(k);
nameOverlaysHere();
}, [urlDep]);
}
/** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
* other active `useBackToExit` (LIFO) so nested views peel off one Back at a time — e.g. a player's
* fullscreen sits above its maximise, which sits above the player itself: Back exits fullscreen,
* then maximise, then closes the player. Leaving a layer by its own control (button/Esc) flips
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
export function useBackToExit(active: boolean, onExit: () => void): void {
const cb = useEffectEvent(onExit);
useEffect(() => {
if (!active) return;
ensureOverlayListener();
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
const idx = overlayStack.indexOf(entry);
if (idx !== -1) overlayStack.splice(idx, 1);
scheduleReconcile();
};
}, [active]);
}