Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all verified) on the 22-commit branch: - collections: split the mutate-then-resync error surface. A resync READ that fails after a successful Plex mutation no longer reports a false 422 "Plex write failed" + rollback; it returns 502 "sync pending" (the next full sync reconciles the mirror). New _write_or_pending_error helper; delete is exempt (no Plex read). Covered by new happy-path / 422-rollback / 502 tests. - New-collection dup-name guard is now advisory only: existingNames is the search-scoped, all-library union, but the backend enforces uniqueness PER library — so it no longer blocks a valid same-name collection in another library, and the backend 409 is localized to nameTaken. - member-remove now confirms before the write-through (matches delete-collection). - add-title / new-collection library search render an error state on request failure instead of a misleading "No matches". - history: seed navCounter past the current entry's persisted sfScroll on load so post-reload ids can't collide with back-stack entries and mis-restore scroll. - PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded). - share isIntrinsicReadOnly between the browse tile and the info page so the lock/edit affordance can't drift.
232 lines
11 KiB
TypeScript
232 lines
11 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 { 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 <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);
|
|
}
|
|
|
|
// --- 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;
|
|
open: (next: T) => 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);
|
|
}, []);
|
|
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]);
|
|
}
|