// 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. Messages thread → list) 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 { useEffect, useRef, useState } from "react"; import { urlWithParams } from "./urlState"; // --- 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); } // --- 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; } export function useHistorySubview( root: T, url?: SubviewUrl, ): { view: T; open: (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 readViewRef = useRef(readView); readViewRef.current = readView; const [view, setView] = useState(readView); useEffect(() => { // Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL). const onPop = () => setView(readViewRef.current()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); return { view, open: (next: T) => { setView(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( { ...window.history.state, _sub: next, sfScroll: nextNavId() }, "", href, ); notifyNav(); }, back: () => window.history.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. type Overlay = { token: number; cb: () => void }; const overlayStack: Overlay[] = []; let overlayCounter = 0; // 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; // 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; while (historyDepth < want) { historyDepth++; window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, ""); } while (historyDepth > want) { historyDepth--; pendingProgrammatic++; window.history.back(); } } 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 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(); }); } /** 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 = useRef(onClose); cb.current = onClose; useEffect(() => { ensureOverlayListener(); const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() }; 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 = useRef(onExit); cb.current = onExit; useEffect(() => { if (!active) return; ensureOverlayListener(); const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() }; overlayStack.push(entry); scheduleReconcile(); return () => { const idx = overlayStack.indexOf(entry); if (idx !== -1) overlayStack.splice(idx, 1); scheduleReconcile(); }; }, [active]); }