refactor(frontend): a stable useHistorySubview, and PlexBrowse can stop disabling the check (E27 S2)

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.
This commit is contained in:
2026-09-21 00:20:05 +02:00
parent ea6d829c29
commit ce08fc2aec
3 changed files with 40 additions and 25 deletions
+15 -6
View File
@@ -9,7 +9,7 @@
// 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
@@ -143,9 +143,14 @@ export function useHistorySubview<T>(
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return {
view,
open: (next: T, opts?: { replace?: boolean }) => {
// `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) {
@@ -165,8 +170,12 @@ export function useHistorySubview<T>(
);
notifyNav();
},
back: () => window.history.back(),
};
[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) -------------------------------