// 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. // // setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. import { useCallback, useEffect, useEffectEvent, useMemo, 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. */ 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, ""); return st; } // --- 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 one more history.back() still land in this document? */ export function backStaysInDocument(): boolean { const nav = (window as { navigation?: NavigationLike }).navigation; const at = nav?.currentEntry?.index; if (nav == null || at == null) return true; return nav.entries()[at - pendingProgrammatic - 1]?.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; 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); const readLandedView = useEffectEvent(readView); 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); const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; if (opts?.replace) { // 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. window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); notifyNav(); return; } // 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], ); // 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, back }), [view, open, back]); } // --- 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. type Overlay = { token: number; cb: () => void }; const overlayStack: Overlay[] = []; 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; // Programmatic history.back() calls still awaiting their popstate — each must be ignored. let pendingProgrammatic = 0; 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++; window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, ""); } while (historyDepth > want) { historyDepth--; if (backStaysInDocument()) { pendingProgrammatic++; window.history.back(); } else { // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record; window.history.replaceState(st, ""); } } syncWaiters.forEach((cb) => cb()); } function scheduleReconcile(): void { if (reconcileScheduled) return; reconcileScheduled = true; queueMicrotask(reconcileHistory); } function ensureOverlayListener(): void { if (listenerInstalled) return; listenerInstalled = true; window.addEventListener("popstate", () => { if (pendingProgrammatic > 0) { pendingProgrammatic--; // 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(); 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. if (historyDepth > 0) historyDepth--; 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. */ export function useBackToClose(onClose: () => void): void { const cb = useEffectEvent(onClose); useEffect(() => { ensureOverlayListener(); const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() }; overlayStack.push(entry); scheduleReconcile(); return () => { 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(); }; }, []); } /** 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]); }