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
-20
View File
@@ -183,25 +183,5 @@
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/plexPlayerHooks.subtitle.dom.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/lib/plexPlayerHooks.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/useCardPager.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/useFocusKeeper.restore.dom.test.tsx": {
"react-hooks/globals": {
"count": 1
}
}
}
+39 -33
View File
@@ -1,5 +1,6 @@
import {
useEffect,
useEffectEvent,
useRef,
useState,
type Dispatch,
@@ -133,20 +134,20 @@ export function useYtVolume({
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
});
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
const onWheel = (e: WheelEvent) => onWheelSpin(e);
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dialogRef]);
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
@@ -183,18 +184,19 @@ export function useNativeControlsYield({
setNativeControls: (on: boolean) => void;
} {
const [nativeControls, setNativeControls] = useState(false);
const handOverToIframe = useEffectEvent(() => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
if (f && document.activeElement === f) setNativeControls(true);
});
const rearmOurControls = useEffectEvent(() => {
setNativeControls(false);
focusModal();
});
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const onBlur = () => window.setTimeout(() => handOverToIframe(), 0);
const rearm = () => rearmOurControls();
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
@@ -204,8 +206,7 @@ export function useNativeControlsYield({
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [stageRef]);
return { nativeControls, setNativeControls };
}
@@ -264,19 +265,17 @@ export function useYtBigView({
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
const syncFullscreen = useEffectEvent(() => {
const fsEl = fullscreenElement();
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
});
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
// eslint-disable-next-line react-hooks/exhaustive-deps
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
}, []);
return {
@@ -485,6 +484,13 @@ export function useYtProgress({
}
playerRef.current = null;
};
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
// every listener attached to it. Nothing in the body reads a render value — the mutable state it
// needs mid-session (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef,
// errorStreakRef, playerRef) is all behind refs, which is the enforcement, not this comment.
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
// object literal, and an effect event may not be passed around as a value.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId]);
}
@@ -330,6 +330,7 @@ function TrackHarness() {
absRef: { current: 3600 },
loadStartRef: { current: 0 },
});
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
harness.current = tracks;
return (
<video>
+10 -4
View File
@@ -426,8 +426,14 @@ export function useAutoSkip(opts: {
setSkipProgress(null);
}, []);
cancelAutoSkipRef.current = cancelAutoSkip;
// The marker is identified by type+offset rather than by object identity: `markers?.find` returns
// the same element while the playhead stays inside it, but a refetch of the markers list would
// hand back an equal-but-new object and restart a countdown already under way. The marker itself
// is then read from the ref above, so the dep list names every reactive input it has.
const activeMarkerType = activeMarker?.type;
const activeMarkerStart = activeMarker?.start_s;
useEffect(() => {
const m = activeMarker;
const m = activeMarkerRef.current;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits;
@@ -453,14 +459,14 @@ export function useAutoSkip(opts: {
window.clearInterval(iv);
skipTimerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeMarker?.type,
activeMarker?.start_s,
activeMarkerType,
activeMarkerStart,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
doSkip,
videoRef,
]);
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
+7 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARD_PAGE_SIZES, parseCardSize } from "./channelLayout";
import { readAccount, writeAccount } from "./storage";
@@ -18,12 +18,14 @@ export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
const safePage = Math.min(page, totalPages - 1);
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: the clamp
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: `safePage`
// above only HIDES that, so commit it or it springs back the moment the list grows again. Only
// fires when the page you are on stopped existing, so it never costs you your place otherwise.
useEffect(() => {
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
}, [enabled, page, totalPages]);
//
// Adjusted DURING RENDER, not from an effect: React re-runs this component immediately and throws
// the first pass away, so nothing is committed with an out-of-range page and there is no second
// paint. The condition is what makes that safe — the very write that triggers it also falsifies it.
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
const paged = !enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
+3 -2
View File
@@ -99,8 +99,9 @@ export function useChipOverflowFit<R>(args: {
const ro = new ResizeObserver(run);
ro.observe(row);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deps]);
// The three refs are stable by construction, so naming them costs nothing and `deps` stays the
// only thing that actually re-measures.
}, [deps, rowRef, chipsRef, pillRef]);
return result;
}
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// The outside-press half of useDismiss. The hook subscribes its `mousedown` listener once per
// open/close, which is what keeps a popover from re-subscribing on every parent render — but it
// means the listener must not CAPTURE the caller's handler or the ref LIST, or it keeps using the
// versions that existed when the popover opened. Both are read through refs (E27 S2); these tests
// are what says so.
import { useRef, useState } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { useDismiss } from "./useDismiss";
afterEach(cleanup);
/** A panel that stays open while the caller swaps the handler it wants called. */
function SwappableHandler({ first, second }: { first: () => void; second: () => void }) {
const panel = useRef<HTMLDivElement>(null);
const [useSecond, setUseSecond] = useState(false);
useDismiss(true, useSecond ? second : first, panel);
return (
<div>
<div ref={panel}>panel</div>
<button onClick={() => setUseSecond(true)}>swap</button>
<div data-testid="outside">outside</div>
</div>
);
}
/** A panel that starts out registering one element and adds a second while it is open. */
function GrowingRefs({ onClose }: { onClose: () => void }) {
const panel = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [withTrigger, setWithTrigger] = useState(false);
useDismiss(true, onClose, withTrigger ? [panel, trigger] : [panel]);
return (
<div>
<div ref={panel}>panel</div>
<button onClick={() => setWithTrigger(true)}>register trigger</button>
<button ref={trigger}>trigger</button>
<div data-testid="outside">outside</div>
</div>
);
}
describe("useDismiss · outside press", () => {
it("closes on a press outside and stays put on a press inside", () => {
const onClose = vi.fn();
render(<SwappableHandler first={onClose} second={vi.fn()} />);
fireEvent.mouseDown(screen.getByText("panel"));
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls the handler the caller has NOW, not the one it had when the popover opened", () => {
const first = vi.fn();
const second = vi.fn();
render(<SwappableHandler first={first} second={second} />);
fireEvent.click(screen.getByText("swap"));
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();
});
it("honours a ref the caller started passing after the popover opened", () => {
const onClose = vi.fn();
render(<GrowingRefs onClose={onClose} />);
fireEvent.mouseDown(screen.getByText("trigger"));
expect(onClose).toHaveBeenCalledTimes(1); // not registered yet — outside
onClose.mockClear();
fireEvent.click(screen.getByText("register trigger"));
fireEvent.mouseDown(screen.getByText("trigger"));
expect(onClose).not.toHaveBeenCalled(); // now it is one of ours
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+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]);
}
@@ -34,6 +34,7 @@ function Page() {
const ref = useRef<HTMLElement>(null);
useFocusKeeper(ref);
const [view, setView] = useState<"grid" | "detail">("grid");
// eslint-disable-next-line react-hooks/globals -- a TEST probe: the test drives the view switch from outside the tree, because useFocusKeeper's whole subject is what happens to focus across that switch — triggering it through a button would put focus on the button first and measure nothing.
show = setView;
return <main ref={ref}>{view === "grid" ? <Grid /> : <button>Back to library</button>}</main>;
}
+6 -6
View File
@@ -83,16 +83,16 @@ export function usePref<T>(
return p ? p(raw) : (raw as T);
}, []);
const seed = useCallback((): T => {
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
// place in this file that had to be excused from the checker (it read `defaultValue` without
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
const [value, setValue] = useState<T>(() => {
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
if (server !== undefined) return norm(server);
if (localKey) return norm(readAccount(localKey, defaultValue));
return defaultValue;
// defaultValue / serverKey / localKey are stable for a given call site.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qc, serverKey, localKey, norm]);
const [value, setValue] = useState<T>(seed);
});
const saveTimer = useRef<number | undefined>(undefined);
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { urlWithParams } from "./urlState";
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
@@ -10,8 +10,12 @@ export function useUrlMirror(updates: Record<string, string | null>): void {
// The caller typically passes an inline object literal (new identity every render); key on its
// serialized value so the effect only fires when a param actually changes, not every render.
const dep = JSON.stringify(updates);
// The object itself is read through a ref, so the effect's one reactive input is the serialized
// value — which is exactly what the dep list now says. The house pattern (see useChipOverflowFit's
// decideRef, usePref's parseRef): a ref for the identity that churns, a primitive for the trigger.
const updatesRef = useRef(updates);
updatesRef.current = updates;
useEffect(() => {
window.history.replaceState(window.history.state, "", urlWithParams(updates));
// eslint-disable-next-line react-hooks/exhaustive-deps
window.history.replaceState(window.history.state, "", urlWithParams(updatesRef.current));
}, [dep]);
}