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:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useReducer, useRef } from "react";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLatest } from "./useLatest";
|
||||
|
||||
// A generic, reusable undo/redo container for a single piece of state.
|
||||
//
|
||||
@@ -9,8 +10,9 @@ import { useCallback, useReducer, useRef } from "react";
|
||||
// onApply (use it when the underlying data is reloaded from the source of truth).
|
||||
//
|
||||
// Not playlist-specific: any component with an undoable value (sort order, a draft, a set
|
||||
// of filters…) can use it. State lives in a ref so the callbacks are stable and never read
|
||||
// a stale snapshot; a reducer-based force-render keeps the view in sync.
|
||||
// of filters…) can use it. The history is state (what render reads) mirrored in a ref (what the
|
||||
// stable callbacks read, so two calls in one tick chain on each other, never on a stale snapshot).
|
||||
// Render never reads the ref: a compiled component would otherwise memoize a stale value (E32).
|
||||
export interface Undoable<T> {
|
||||
value: T;
|
||||
set: (next: T) => void;
|
||||
@@ -32,10 +34,18 @@ export function useUndoable<T>(
|
||||
initial: T,
|
||||
opts?: { onApply?: (value: T) => void; limit?: number },
|
||||
): Undoable<T> {
|
||||
const [, force] = useReducer((n: number) => n + 1, 0);
|
||||
const ref = useRef<History<T>>({ past: [], present: initial, future: [] });
|
||||
const onApplyRef = useRef(opts?.onApply);
|
||||
onApplyRef.current = opts?.onApply;
|
||||
const [h, setH] = useState<History<T>>(() => ({ past: [], present: initial, future: [] }));
|
||||
const ref = useRef(h);
|
||||
// The mirror follows every commit, so a `reset` made during render (a caller re-seeding on a new
|
||||
// selection) reaches the callbacks without a render-phase ref write.
|
||||
useLayoutEffect(() => {
|
||||
ref.current = h;
|
||||
}, [h]);
|
||||
const commit = useCallback((next: History<T>) => {
|
||||
ref.current = next;
|
||||
setH(next);
|
||||
}, []);
|
||||
const onApplyRef = useLatest(opts?.onApply);
|
||||
const limit = opts?.limit ?? 100;
|
||||
|
||||
const set = useCallback(
|
||||
@@ -44,39 +54,36 @@ export function useUndoable<T>(
|
||||
if (Object.is(next, s.present)) return;
|
||||
const past = [...s.past, s.present];
|
||||
if (past.length > limit) past.shift();
|
||||
ref.current = { past, present: next, future: [] };
|
||||
force();
|
||||
commit({ past, present: next, future: [] });
|
||||
onApplyRef.current?.(next);
|
||||
},
|
||||
[limit],
|
||||
[limit, commit, onApplyRef],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const s = ref.current;
|
||||
if (!s.past.length) return;
|
||||
const prev = s.past[s.past.length - 1]!; // past is non-empty (guarded above)
|
||||
ref.current = {
|
||||
commit({
|
||||
past: s.past.slice(0, -1),
|
||||
present: prev,
|
||||
future: [s.present, ...s.future],
|
||||
};
|
||||
force();
|
||||
});
|
||||
onApplyRef.current?.(prev);
|
||||
}, []);
|
||||
}, [commit, onApplyRef]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const s = ref.current;
|
||||
if (!s.future.length) return;
|
||||
const next = s.future[0]!; // future is non-empty (guarded above)
|
||||
const rest = s.future.slice(1);
|
||||
ref.current = { past: [...s.past, s.present], present: next, future: rest };
|
||||
force();
|
||||
commit({ past: [...s.past, s.present], present: next, future: rest });
|
||||
onApplyRef.current?.(next);
|
||||
}, []);
|
||||
}, [commit, onApplyRef]);
|
||||
|
||||
// State only: safe to call during render. The other actions see it from the next commit on.
|
||||
const reset = useCallback((value: T) => {
|
||||
ref.current = { past: [], present: value, future: [] };
|
||||
force();
|
||||
setH({ past: [], present: value, future: [] });
|
||||
}, []);
|
||||
|
||||
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
|
||||
@@ -84,17 +91,18 @@ export function useUndoable<T>(
|
||||
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
|
||||
// the user's reorder history valid and usable instead of throwing it away (which is what a
|
||||
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
|
||||
const rebase = useCallback((map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
ref.current = {
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
};
|
||||
force();
|
||||
}, []);
|
||||
const rebase = useCallback(
|
||||
(map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
commit({
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
});
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const h = ref.current;
|
||||
return {
|
||||
value: h.present,
|
||||
set,
|
||||
|
||||
Reference in New Issue
Block a user