Files
siftlode/frontend/src/lib/history.ts
T
peter 759a7536b9 fix(history): an overlay opened while a back() is in flight waits to push its entry
The Plex player's ⋯ → Settings closed the menu (its entry went back) and opened the panel a task
later, before that back() had landed. The panel's push was the entry the landing back() took, so
the books counted one entry more than the history held, and closing the panel by a click outside
it stepped back over the player's own entry: the player closed.

A push now waits while a back() of ours is in flight; the popstate that lands the last one
reconciles again.
2026-10-14 19:43:51 +02:00

463 lines
24 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.
//
// 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 <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. */
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;
}
// --- 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<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;
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);
const readLandedView = useEffectEvent(readView);
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);
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<string, unknown>;
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]);
}