refactor(frontend): the shared hooks in lib/ stop lying about their deps (E27 S2)

Ten more suppressions, and React 19 turns out to supply the missing primitive.

useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.

The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.

Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
  different handler while it stayed open kept getting the OLD one called. The disabled dep list
  asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
  list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
  lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
  React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
  the dep list names its real inputs. That also made the set-state-in-effect finding go away:
  the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.

Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.

Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
This commit is contained in:
2026-09-21 00:07:12 +02:00
parent 7e1aeeb9fe
commit 6a7ff330f6
11 changed files with 175 additions and 79 deletions
+16 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, type RefObject } from "react";
import { useEffect, useRef, type RefObject } from "react";
import { useLayer } from "./layerStack";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
@@ -26,19 +26,29 @@ export function useDismiss(
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
// that lands outside every registered element.
// Both arguments are read through refs so the listener can stay subscribed once per open/close —
// which is the point of the `[active]` dep list — without going STALE. The disabled dep list this
// replaces asserted that onClose was "read fresh from this run's closure"; it was not. The closure
// is built when `active` last flipped, so a caller that re-rendered with a different handler while
// the popover stayed open kept getting the old one called (regression test in useDismiss.dom.test).
// `refs` is the weaker case — the ref OBJECTS are stable, so a late-mounting node is seen either
// way — but a caller that changes WHICH refs it passes while open was equally ignored.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const refsRef = useRef(refs);
refsRef.current = refs;
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
const onDown = (e: MouseEvent) => {
const current = refsRef.current;
const list = Array.isArray(current) ? current : [current];
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
if (!list.some((r) => r.current?.contains(target))) onCloseRef.current();
};
document.addEventListener("mousedown", onDown);
return () => {
document.removeEventListener("mousedown", onDown);
};
// Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
// onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}