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
+20 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
@@ -70,30 +70,29 @@ export default function Toaster({
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
// toast up in the very batch that handed the app host the top spot, so it was classified as
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
// host's last screen either way.
// inherited and announced by neither path (deep-review round 5). The top host records its screen
// after each commit (the layout effect below); a host taking over reads it in a LATER render, after
// that commit, so it always sees the outgoing host's last committed screen. (It was written during
// render, which made the React Compiler skip the Toaster — E32 — and let a discarded render
// overwrite it.)
//
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
// first pass had already overwritten it on the line below, so the takeover host classified its
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
const handover = useRef<{ top: boolean; ids: ReadonlySet<number> }>({
// Snapshotted into state when `isTop` flips, adjusted DURING render — not a `useMemo`, which React
// may recompute (and the compiler is free to): a recompute while top would re-read this host's OWN
// screen and silence its next toast. Under StrictMode the double invocation reads the same,
// unchanged global, so both passes agree (the round-6 failure was a render-phase WRITE racing it).
const [handover, setHandover] = useState<{ top: boolean; ids: ReadonlySet<number> }>({
top: false,
ids: EMPTY_IDS,
});
if (handover.current.top !== isTop)
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
const inherited = handover.current.ids;
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
// same value twice is harmless; that is the point of the ref.
// eslint-disable-next-line react-hooks/globals -- the handover between toast hosts is module-level BY DESIGN and must be written during render — see the 12 lines above, which record why a useMemo produced the wrong answer under StrictMode. The write is idempotent, which is what makes a repeated render invocation safe.
if (isTop) lastTopIds = toasts.map((n) => n.id);
let inherited = handover.ids;
if (handover.top !== isTop) {
const next = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
setHandover(next);
inherited = next.ids;
}
useLayoutEffect(() => {
if (isTop) lastTopIds = toasts.map((n) => n.id);
});
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!isTop) {