useHistorySubview returned a fresh object with a fresh `open` on every render. Correct, but it made "re-run when the sub-view moves" unwriteable: PlexBrowse had three effects that wanted exactly that and had to disable the dependency check to get it, and a fourth that listed `sub` honestly and therefore re-ran on EVERY parent render, saved only by a ref guard. Fixed at the source, which is where the house rule says a principle belongs: `open` and `back` are useCallbacks and the returned object is memoised. `url` is a module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only module functions, so there is nothing left to churn on. All three PlexBrowse dep lists are now the honest ones, and the comment that claimed `open` was "behaviourally stable" is replaced by it actually being stable. PlexBrowse's remaining four are escape hatches, and two of them are now ONE: - `infoScrollRef` is written from four places, and the compiler objects to a ref being modified outside the effect that reads it. That objection is a conservative false positive — a scroll memory needs exactly that arrangement — but four bare mutations were worth concentrating anyway. One `rememberInfoScroll` writer now holds the "stash on the way out, reset on a fresh page" rule, and answers the checker once instead of three times. - The language-commit effect cannot move into render: it resets PlexProvider's filters, and a render-phase setState may only touch the component's own state. Said so at the site. Suppressions 78 -> 75; exhaustive-deps 23 -> 20.
405 lines
20 KiB
TypeScript
405 lines
20 KiB
TypeScript
// 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||
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 <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 (deep-review E11 round 1).
|
||
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. */
|
||
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, "");
|
||
return st;
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
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;
|
||
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 readViewRef = useRef(readView);
|
||
readViewRef.current = readView;
|
||
const [view, setView] = useState<T>(readView);
|
||
useEffect(() => {
|
||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||
const onPop = () => setView(readViewRef.current());
|
||
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.
|
||
const open = useCallback(
|
||
(next: T, opts?: { replace?: boolean }) => {
|
||
setView(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: next }, "", 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: next, sfScroll: nextNavId() },
|
||
"",
|
||
href,
|
||
);
|
||
notifyNav();
|
||
},
|
||
[url],
|
||
);
|
||
const back = useCallback(() => window.history.back(), []);
|
||
// 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`). The design before
|
||
// E11 let the push land above the overlays and "buried" their entries with a counter, and that
|
||
// counter was a whole family of bugs — a burial spent by a different overlay closing first, a
|
||
// navigation that could not report whether it pushed and was assumed to have, one browser Back that
|
||
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
|
||
// last two measured in Chrome, E11 S1). With nothing ever buried, the stack and the history agree by
|
||
// construction.
|
||
type Overlay = { token: number; cb: () => void };
|
||
const overlayStack: Overlay[] = [];
|
||
let overlayCounter = 0;
|
||
// 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.
|
||
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();
|
||
}
|
||
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
|
||
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, and one entry can hold several steps: the Plex player's
|
||
// panels share a single entry and Back peels one per press. Navigating over 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. The helper before E11 waited for "a popstate" plus a 250 ms
|
||
* fallback — but one reconcile can issue several back()s, an overlay closed in the tick it opened
|
||
* issues none, and the fallback then ran with back()s still in flight. Here the reconcile and every
|
||
* popstate re-check one predicate.
|
||
*
|
||
* `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 = 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]);
|
||
}
|