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
+28 -22
View File
@@ -1,4 +1,11 @@
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffectEvent,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useScrollFade } from "../../lib/useScrollFade";
import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper";
import { StickyTopProvider } from "./stickyTop";
@@ -130,12 +137,11 @@ export default function PageScroller({
setStickyTops((n) => n + 1);
return () => setStickyTops((n) => n - 1);
}, []);
const fade = useScrollFade(20, stickyTops === 0);
const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
const fabUp = useBackToTopShown();
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
// re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
// once here is safe.
const setRef = useCallback(
@@ -150,29 +156,31 @@ export default function PageScroller({
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
useFocusKeeper(elRef);
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
const scrollKeyRef = useRef(scrollKey);
scrollKeyRef.current = scrollKey;
const lastComposite = useRef<string | null>(null);
const cancelRef = useRef<() => void>(() => {});
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
// unchanged, so it's safe to call from several triggers.
const resolve = useCallback(() => {
// unchanged, so it's safe to call from several triggers. An effect event: the listeners below are
// bound once, not per scrollKey, and still read the live one.
const resolve = useEffectEvent(() => {
const el = elRef.current;
if (!el || scrollKeyRef.current == null) return;
const key = `${ensureNavId()}:${scrollKeyRef.current}`;
if (!el || scrollKey == null) return;
const key = `${ensureNavId()}:${scrollKey}`;
if (key === lastComposite.current) return;
lastComposite.current = key;
cancelRef.current();
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
}, []);
});
const remember = useEffectEvent((el: HTMLElement) => {
if (scrollKey == null) return;
rememberScroll(`${peekNavId()}:${scrollKey}`, el.scrollTop);
});
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
useLayoutEffect(() => {
resolve();
}, [scrollKey, resolve]);
}, [scrollKey]);
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
@@ -180,20 +188,18 @@ export default function PageScroller({
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return;
const onScroll = () => {
if (scrollKeyRef.current == null) return;
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
};
const onScroll = () => remember(el);
const onNav = () => resolve();
el.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("popstate", resolve);
const unsubNav = subscribeNav(resolve);
window.addEventListener("popstate", onNav);
const unsubNav = subscribeNav(onNav);
return () => {
cancelRef.current();
el.removeEventListener("scroll", onScroll);
window.removeEventListener("popstate", resolve);
window.removeEventListener("popstate", onNav);
unsubNav();
};
}, [resolve]);
}, []);
return (
// `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab
@@ -204,7 +210,7 @@ export default function PageScroller({
id={CONTENT_ID}
tabIndex={-1}
{...FOCUS_REST_ATTRS}
style={fade.style}
style={fadeStyle}
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
>
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>