// 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
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; 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 { // 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; 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 { /** 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( root: T, url?: SubviewUrl, ): { 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 // ? 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(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 ? 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(next: T, url: SubviewUrl | 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; 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(); 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; 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(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]); }