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
+37 -21
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Button } from "./Button";
@@ -78,6 +78,23 @@ export interface ComboboxProps {
labelsPending?: boolean;
}
/** Remember each value's display label as options flow through, so a selected chip can name itself
* even after the option leaves the query-narrowed list (async single-select). State, merged DURING
* render: a ref written in an effect was read by the render before it, so a chip could name itself
* by its raw value until something else re-rendered (E32). */
function useLabelCache(options: readonly ComboboxOption[]): Record<string, string> {
const [labelCache, setLabelCache] = useState<Record<string, string>>({});
const newLabels = options.filter(
(o): o is typeof o & { label: string } => o.label != null && labelCache[o.value] !== o.label,
);
if (newLabels.length)
setLabelCache((c) => ({
...c,
...Object.fromEntries(newLabels.map((o) => [o.value, o.label])),
}));
return labelCache;
}
export default function Combobox({
label,
options,
@@ -108,17 +125,15 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
const inputRef = useRef<HTMLInputElement | null>(null);
const fade = useScrollFade();
// The input node as STATE, not a ref: the handlers below are handed to getReferenceProps during
// render, and a ref inside them made the React Compiler skip the whole component (E32).
const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
// even after the option leaves the query-narrowed list (async single-select). Caller-supplied
// `selectedLabels` win (they survive a page reload where nothing has been fetched yet).
const labelCache = useRef<Record<string, string>>({});
useEffect(() => {
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
}, [options]);
const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
// Caller-supplied `selectedLabels` win over the cache (they survive a page reload where nothing
// has been fetched yet).
const labelCache = useLabelCache(options);
const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache[v];
const labelFor = (v: string) => knownLabel(v) ?? v;
const setOpenAnd = (next: boolean) => {
@@ -134,18 +149,19 @@ export default function Combobox({
if (!next) setQuery("");
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
const { setReference, setPositionReference } = pop.refs;
// The input is the combobox reference for floating-ui AND our own focus target (the field's
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
// node to BOTH — without it the node stays null, so the field's onMouseDown guard (`e.target !==
// inputEl`) is always true and preventDefault() cancels the input's own click-focus, making
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
// every render.
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
pop.refs.setReference(node);
inputRef.current = node;
setReference(node);
setInputEl(node);
},
[pop.refs],
[setReference],
);
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
@@ -173,7 +189,7 @@ export default function Combobox({
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
// Keep the field focused so the user can keep picking / typing; the list stays open.
inputRef.current?.focus();
inputEl?.focus();
} else {
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
// immediately re-open the list (now that the merged ref makes focus() actually work).
@@ -187,7 +203,7 @@ export default function Combobox({
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole
box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
<div
ref={pop.refs.setPositionReference}
ref={setPositionReference}
onMouseDown={(e) => {
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
// focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its
@@ -197,9 +213,9 @@ export default function Combobox({
e.preventDefault();
return;
}
if (e.target !== inputRef.current) {
if (e.target !== inputEl) {
e.preventDefault();
inputRef.current?.focus();
inputEl?.focus();
setOpenAnd(true);
}
}}
@@ -277,7 +293,7 @@ export default function Combobox({
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
<div ref={fadeRef} style={fadeStyle} className="max-h-64 overflow-y-auto no-scrollbar">
{/* The listbox holds the options alone; the lines about the list sit beside it (F4). */}
<PopoverList>
{!showError &&