refactor(ui): every function compiles under the React Compiler but VirtualGrid, and react-hooks/refs is on

E32 S4 — the burn-down from 29 skipped functions to 1:

- try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into
  their own function (push, the setup wizard's next, the share page's unlock).
- A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo
  does not see it: the value is computed before the try, or the try lives in a module-level helper
  (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times).
- The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through
  one effect event, read once when the player is built; PlexPlayer's unmount relies on the
  leaving-the-item cleanup for the generation bump it duplicated.
- What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias,
  the loadSession self-reference (declared early, set after commit); PlayerModal's session-position
  read during render (now where the player starts), navRef/modeRef writes, and a description helper
  that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's
  latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default
  among same-filter views, so the seed needs no sticky write — test seen red on the old helper).
- A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly
  compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail
  threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old
  contract test claimed the call was safe inside any `use…` function, which was false — replaced by
  a render test that is red under the old name.
- PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer
  stack: removed.

react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid
(IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
This commit is contained in:
2026-10-04 00:05:37 +02:00
parent a486c7521a
commit d7fb7075b8
42 changed files with 646 additions and 589 deletions
+35 -61
View File
@@ -6,7 +6,6 @@ import {
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
@@ -14,7 +13,7 @@ import { useNavigationActions } from "./navigationContext";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
import { PlayerTransportControls } from "./playerModalControls";
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
import {
AlertTriangle,
@@ -35,6 +34,7 @@ import {
X,
} from "lucide-react";
import Avatar from "./ui/Avatar";
import { useLatest, useStableCallback } from "../lib/useLatest";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
@@ -56,13 +56,11 @@ import {
formatCompact,
relativeTime,
} from "../lib/format";
import { renderDescription } from "./descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster from "./ui/Toaster";
import { StateMessage } from "./ui/QueryState";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
import {
@@ -185,13 +183,12 @@ export default function PlayerModal({
// The launch queue is frozen at mount, so its items keep the position they were snapshotted with;
// without this map, stepping next→prev back to an item played earlier in the SAME session would
// resume at that snapshot and then persist it over the newer position.
// A position this session already stored wins over `resumeAt` — it is the freshest truth we have —
// so the map is read where the player starts (useYtProgress, loadVideo), never during render.
const sessionPosRef = useRef<Map<string, number>>(new Map());
const sessionPos = sessionPosRef.current.get(active.id);
// startAt only applies to the item we opened with; advanced items resume their own pos. A
// position this session already stored wins over both — it is the freshest truth we have.
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
sessionPos ??
(startAt != null && active.id === video.id ? startAt : active.position_seconds || 0);
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
@@ -277,17 +274,20 @@ export default function PlayerModal({
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
const [playerError, setPlayerError] = useState<number | null>(null);
const loadVideo = (id: string, start: number | null) => {
// Stable callbacks, not plain closures: both reach the player through refs, and the description's
// links get them during render (see PlayerDescription).
const loadVideo = useStableCallback((id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the active item, start others at 0.
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
const startSeconds =
start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id;
setCurrentVideoId(id);
setLiveData(null);
};
});
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
@@ -336,19 +336,18 @@ export default function PlayerModal({
currentIdRef.current = active.id;
}, [active.id]);
const seekTo = (seconds: number) => {
const seekTo = useStableCallback((seconds: number) => {
const p = playerRef.current;
if (p && typeof p.seekTo === "function") {
p.seekTo(seconds, true);
p.playVideo?.();
}
};
});
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
// queue+index from a ref so the window keydown handler (bound once) always steps from the
// current position, not a stale closure.
const navRef = useRef({ queue, index });
navRef.current = { queue, index };
const navRef = useLatest({ queue, index });
const goPrev = () => {
const { queue: q, index: i } = navRef.current;
if (q && i > 0) setPlayingId(q[i - 1]!.id);
@@ -371,13 +370,9 @@ export default function PlayerModal({
if (!onRemoveFromList || removingRef.current) return;
const victim = active;
removingRef.current = true;
let done = false;
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
done = await onRemoveFromList(victim);
} finally {
const done = await onRemoveFromList(victim).finally(() => {
removingRef.current = false;
}
});
if (!done) return; // the request failed — leave playback alone
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
// and auto-advance keep working while the request runs, and a cursor from before the await
@@ -409,8 +404,7 @@ export default function PlayerModal({
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
const modeRef = useRef({ autoMode, loopMode });
modeRef.current = { autoMode, loopMode };
const modeRef = useLatest({ autoMode, loopMode });
const cycleAuto = () => {
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
};
@@ -609,29 +603,13 @@ export default function PlayerModal({
qc,
});
// The description, said the same way in its two places (the popover under 1280 px, the panel
// beside the video above it): loading, failed with a retry, the text, or none. One ladder, so the
// two cannot drift (E31 review 2: the failed state was tested in the panel only). `wrap` is each
// place's own box for the text.
const description = (wrap: (body: ReactNode) => ReactNode) =>
detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.isError && !detail.data ? (
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
{t("player.descriptionFailed")}
</StateMessage>
) : detail.data?.description ? (
wrap(
renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
}),
)
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
);
// The description's two places share one ladder (PlayerDescription) and these props.
const descriptionProps = {
detail,
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
};
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
@@ -916,15 +894,12 @@ export default function PlayerModal({
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
{description((body) => (
<div
ref={descFadeRef}
style={descFadeStyle}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{body}
</div>
))}
<PlayerDescription
ref={descFadeRef}
style={descFadeStyle}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
{...descriptionProps}
/>
</PopoverSurface>
<Button
variant="ghost"
@@ -1105,11 +1080,10 @@ export default function PlayerModal({
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
{description((body) => (
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{body}
</div>
))}
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
{...descriptionProps}
/>
</section>
)}
</div>