refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler

E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.

- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
  a ref, so every component holding one was skipped (21 call sites, the engine and its surface
  among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
  else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
  useSyncExternalStore, which React asks on every render instead of a cached memo.

Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
  the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
  The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
  draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
  inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
  "one writer" rule; the writer comes first now and the effect goes through it.

The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
This commit is contained in:
2026-10-03 23:18:26 +02:00
parent dc00b55feb
commit 76358a0d77
68 changed files with 606 additions and 545 deletions
+24 -28
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLatest, useStableCallback } from "../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -167,7 +168,7 @@ export default function Playlists() {
const qc = useQueryClient();
const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
@@ -215,8 +216,7 @@ export default function Playlists() {
// the actual set of items changes (different playlist, or an add/remove).
const lastSetRef = useRef<string>("");
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
const selectedRef = useRef(selectedId);
selectedRef.current = selectedId;
const selectedRef = useLatest(selectedId);
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
@@ -330,12 +330,15 @@ export default function Playlists() {
// push/delete-on-YouTube paths (usePlaylistActions). Folding it in here would have taken drag and remove away from
// every account without the scope, which they have always had.
const canEdit = editable;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const canEditRef = useLatest(canEdit);
// Forward refs to `shown` and `removeItem` (defined further down) for the stable callbacks here,
// pointed at the latest values after each commit (below).
const shownRef = useRef<Video[]>([]);
const removeRef = useRef<typeof removeItem>(async () => false);
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
// than closing over it.
const openPlayer = useCallback((id: string) => {
const openPlayer = useStableCallback((id: string) => {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
@@ -346,18 +349,14 @@ export default function Playlists() {
listId: selectedRef.current,
editable: canEditRef.current,
});
}, []);
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
});
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
// Delete key use — one optimistic update, one undo toast, one undo-stack rebase, no second copy to
// drift. It answers whether the removal actually happened, because the player has to decide
// afterwards whether to step to the next item or close. It removes from the list the player was
// launched from (see `playing.listId`), not from whatever is selected by now.
const launchListId = playing?.listId ?? null;
const removeFromPlayer = useCallback(
(v: Video) => removeRef.current(v, launchListId),
[launchListId],
);
const removeFromPlayer = (v: Video) => removeRef.current(v, launchListId);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
@@ -379,7 +378,7 @@ export default function Playlists() {
playlistColumns({
t,
indexOf: storedIndex,
onPlay,
onPlay: (v) => openPlayer(v.id),
more: (v) => (
<MoreButton
label={t("playlists.rowMenu")}
@@ -389,7 +388,7 @@ export default function Playlists() {
/>
),
}),
[t, storedIndex, onPlay, rowMenuButton, rowMenuOpen, rowMenuTarget],
[t, storedIndex, openPlayer, rowMenuButton, rowMenuOpen, rowMenuTarget],
);
const filterActive = Object.values(filters).some(isActive);
@@ -397,11 +396,6 @@ export default function Playlists() {
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
[items, columns, filters, viewSort, groupBy, lang],
);
// Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext.
const shownRef = useRef(shown);
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
shownRef.current = shown;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
@@ -535,10 +529,12 @@ export default function Playlists() {
});
return true;
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
removeRef.current = removeItem;
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
useLayoutEffect(() => {
shownRef.current = shown;
removeRef.current = removeItem;
});
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
@@ -610,7 +606,7 @@ export default function Playlists() {
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlaying(null), []);
const closePlayer = () => setPlaying(null);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel =
@@ -866,11 +862,11 @@ export default function Playlists() {
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
armRenameReturn(e);
void saveRename(); // plActions.rename reports its own failure
}
if (e.key === "Escape") {
renameFocus.arm(e);
armRenameReturn(e);
setRenaming(false);
}
}}
@@ -902,7 +898,7 @@ export default function Playlists() {
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
<MoreButton
ref={renameFocus.target}
ref={renameTarget}
variant="outline"
label={t("playlists.railMenu")}
expanded={headerMenu.open}