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:
@@ -9,7 +9,7 @@
|
||||
// its own button pops exactly its entry without tripping the modals underneath.
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
@@ -137,12 +137,11 @@ export function useHistorySubview<T>(
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const readViewRef = useRef(readView);
|
||||
readViewRef.current = readView;
|
||||
const [view, setView] = useState<T>(readView);
|
||||
const readLandedView = useEffectEvent(readView);
|
||||
useEffect(() => {
|
||||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||
const onPop = () => setView(readViewRef.current());
|
||||
const onPop = () => setView(readLandedView());
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
@@ -370,11 +369,10 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
|
||||
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||
export function useBackToClose(onClose: () => void): void {
|
||||
const cb = useRef(onClose);
|
||||
cb.current = onClose;
|
||||
const cb = useEffectEvent(onClose);
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
@@ -394,12 +392,11 @@ export function useBackToClose(onClose: () => void): void {
|
||||
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
|
||||
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
|
||||
export function useBackToExit(active: boolean, onExit: () => void): void {
|
||||
const cb = useRef(onExit);
|
||||
cb.current = onExit;
|
||||
const cb = useEffectEvent(onExit);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, type RefObject } from "react";
|
||||
import { anyBlockingLayer, escapeHandled } from "./layerStack";
|
||||
import { createStore } from "./store";
|
||||
import type { Page } from "./urlState";
|
||||
@@ -483,10 +483,9 @@ export function useGlobalKeymap(
|
||||
pages: readonly Page[],
|
||||
): void {
|
||||
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
|
||||
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
|
||||
// provider and `lib/` may not import a component — so App hands it in. An effect event, so a fresh
|
||||
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
|
||||
const navRef = useRef(onNavigate);
|
||||
navRef.current = onNavigate;
|
||||
const navigate = useEffectEvent(onNavigate);
|
||||
useKeyScope("page", enabled);
|
||||
const pagesKey = pages.join(",");
|
||||
useEffect(() => {
|
||||
@@ -506,7 +505,7 @@ export function useGlobalKeymap(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (!s.page || !navigablePages.get().includes(s.page)) return;
|
||||
ev.preventDefault();
|
||||
navRef.current(s.page);
|
||||
navigate(s.page);
|
||||
},
|
||||
]),
|
||||
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
|
||||
@@ -529,7 +528,7 @@ export function useGlobalKeymap(
|
||||
},
|
||||
home: (ev) => {
|
||||
ev.preventDefault();
|
||||
navRef.current("home");
|
||||
navigate("home");
|
||||
},
|
||||
});
|
||||
if (!handled) dispatch(e, "page", pageHandlers);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useEffectEvent, useRef } from "react";
|
||||
|
||||
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
|
||||
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
|
||||
@@ -107,24 +107,21 @@ export function useLayer(
|
||||
opts?: { passive?: boolean; trapsTab?: boolean },
|
||||
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
|
||||
const idRef = useRef<number | null>(null);
|
||||
const escRef = useRef(onEscape);
|
||||
escRef.current = onEscape;
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const escape = useEffectEvent(() => onEscape?.());
|
||||
const hasEscape = onEscape !== undefined;
|
||||
const passive = !!opts?.passive;
|
||||
const trapsTab = opts?.trapsTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = pushLayer({
|
||||
passive: !!optsRef.current?.passive,
|
||||
trapsTab: optsRef.current?.trapsTab,
|
||||
});
|
||||
const id = pushLayer({ passive, trapsTab });
|
||||
idRef.current = id;
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
if (hasEscape) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
// takeEscape stamps BEFORE closing, so a `window` listener below (fires after this `document`
|
||||
// one) sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
if (takeEscape(e, id)) escRef.current?.();
|
||||
if (takeEscape(e, id)) escape();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
@@ -133,8 +130,9 @@ export function useLayer(
|
||||
removeLayer(id);
|
||||
idRef.current = null;
|
||||
};
|
||||
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
|
||||
}, [active]);
|
||||
// Re-register when open/closed toggles (the shape flags are constant per caller); the Escape
|
||||
// handler is an effect event, so a new `onEscape` each render never re-registers the layer.
|
||||
}, [active, hasEscape, passive, trapsTab]);
|
||||
|
||||
return {
|
||||
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { scrollParent } from "./listPaging";
|
||||
import { focusIsUnclaimed } from "./useFocusKeeper";
|
||||
@@ -490,8 +490,8 @@ export function useListNav(
|
||||
onDelete?: (index: number) => void;
|
||||
},
|
||||
): void {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
|
||||
const latestOpts = useEffectEvent(() => opts);
|
||||
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
|
||||
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
|
||||
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
|
||||
@@ -503,10 +503,10 @@ export function useListNav(
|
||||
if (!opts.enabled || !el) return;
|
||||
lists.set(el, {
|
||||
el,
|
||||
count: () => optsRef.current.count ?? ownItems(el).length,
|
||||
cols: () => optsRef.current.cols ?? measuredCols(el),
|
||||
reveal: (i) => optsRef.current.reveal?.(i),
|
||||
remove: () => optsRef.current.onDelete ?? null,
|
||||
count: () => latestOpts().count ?? ownItems(el).length,
|
||||
cols: () => latestOpts().cols ?? measuredCols(el),
|
||||
reveal: (i) => latestOpts().reveal?.(i),
|
||||
remove: () => latestOpts().onDelete ?? null,
|
||||
});
|
||||
el.setAttribute(LIST, "");
|
||||
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
|
||||
|
||||
@@ -256,7 +256,6 @@ export function useYtBigView({
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
maximizedRef: RefObject<boolean>;
|
||||
isFullscreenRef: RefObject<boolean>;
|
||||
toggleFullscreen: () => void;
|
||||
toggleTrueFullscreen: () => void;
|
||||
@@ -267,8 +266,6 @@ export function useYtBigView({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const maximizedRef = useRef(false);
|
||||
maximizedRef.current = maximized;
|
||||
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
|
||||
const isFullscreenRef = useRef(false);
|
||||
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
|
||||
@@ -310,7 +307,6 @@ export function useYtBigView({
|
||||
return {
|
||||
isFullscreen,
|
||||
maximized,
|
||||
maximizedRef,
|
||||
isFullscreenRef,
|
||||
toggleFullscreen,
|
||||
toggleTrueFullscreen,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./fullscreen";
|
||||
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { useLatest } from "./useLatest";
|
||||
import i18n, { plexLang } from "../i18n";
|
||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||
@@ -458,17 +459,13 @@ export function useAutoSkip(opts: {
|
||||
seekTo,
|
||||
videoRef,
|
||||
} = opts;
|
||||
const skipProgressRef = useRef<number | null>(null);
|
||||
const cancelAutoSkipRef = useRef<() => void>(() => {});
|
||||
|
||||
const activeMarker: PlexMarker | undefined = markers?.find(
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
);
|
||||
const activeMarkerRef = useRef(activeMarker);
|
||||
activeMarkerRef.current = activeMarker;
|
||||
const activeMarkerRef = useLatest(activeMarker);
|
||||
|
||||
const [skipProgress, setSkipProgress] = useState<number | null>(null);
|
||||
skipProgressRef.current = skipProgress;
|
||||
const skipProgressRef = useLatest(skipProgress);
|
||||
const cancelledMarkerRef = useRef<string | null>(null);
|
||||
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
|
||||
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
|
||||
@@ -479,8 +476,7 @@ export function useAutoSkip(opts: {
|
||||
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
|
||||
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
|
||||
const skipTimerRef = useRef<number | null>(null);
|
||||
const reportWatchedErrorRef = useRef(opts.reportWatchedError);
|
||||
reportWatchedErrorRef.current = opts.reportWatchedError;
|
||||
const reportWatchedErrorRef = useLatest(opts.reportWatchedError);
|
||||
const doSkip = useCallback(
|
||||
(m: PlexMarker) => {
|
||||
setSkipProgress(null);
|
||||
@@ -495,7 +491,7 @@ export function useAutoSkip(opts: {
|
||||
go(nextId, watchedWrite);
|
||||
} else seekTo(m.end_s);
|
||||
},
|
||||
[id, go, nextId, seekTo],
|
||||
[id, go, nextId, seekTo, reportWatchedErrorRef],
|
||||
);
|
||||
const cancelAutoSkip = useCallback(() => {
|
||||
const m = activeMarkerRef.current;
|
||||
@@ -505,8 +501,8 @@ export function useAutoSkip(opts: {
|
||||
skipTimerRef.current = null;
|
||||
}
|
||||
setSkipProgress(null);
|
||||
}, []);
|
||||
cancelAutoSkipRef.current = cancelAutoSkip;
|
||||
}, [activeMarkerRef]);
|
||||
const cancelAutoSkipRef = useLatest(cancelAutoSkip);
|
||||
// The marker is identified by type+offset rather than by object identity: `markers?.find` returns
|
||||
// the same element while the playhead stays inside it, but a refetch of the markers list would
|
||||
// hand back an equal-but-new object and restart a countdown already under way. The marker itself
|
||||
@@ -548,6 +544,7 @@ export function useAutoSkip(opts: {
|
||||
autoSkipDelay,
|
||||
doSkip,
|
||||
videoRef,
|
||||
activeMarkerRef,
|
||||
]);
|
||||
|
||||
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
import { useLatest } from "./useLatest";
|
||||
|
||||
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
|
||||
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
|
||||
@@ -12,7 +13,7 @@ import i18n from "../i18n";
|
||||
*
|
||||
* Timers are keyed by channel id, so a pending flush survives the caller switching channels: it
|
||||
* fires later and reads the right cache via `read(id)`, rather than needing the caller to flush on
|
||||
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through a ref so the returned
|
||||
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through `useLatest` so the returned
|
||||
* callbacks stay stable — the unmount flush then runs only on unmount, never on every render. */
|
||||
export function useChannelPriority(opts: {
|
||||
/** Current priority for this channel from whichever cache the caller owns (undefined if absent). */
|
||||
@@ -27,22 +28,24 @@ export function useChannelPriority(opts: {
|
||||
delay?: number;
|
||||
}) {
|
||||
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const optsRef = useLatest(opts);
|
||||
|
||||
const flush = useCallback((channelId: string) => {
|
||||
const latest = optsRef.current.read(channelId);
|
||||
if (latest == null) return;
|
||||
api
|
||||
.updateChannel(channelId, { priority: latest })
|
||||
.then(() => optsRef.current.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
|
||||
// used to just snap back — often after the stepper had already been let go (E11 S2b).
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
}, []);
|
||||
const flush = useCallback(
|
||||
(channelId: string) => {
|
||||
const latest = optsRef.current.read(channelId);
|
||||
if (latest == null) return;
|
||||
api
|
||||
.updateChannel(channelId, { priority: latest })
|
||||
.then(() => optsRef.current.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
|
||||
// used to just snap back — often after the stepper had already been let go (E11 S2b).
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
},
|
||||
[optsRef],
|
||||
);
|
||||
|
||||
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
|
||||
// optimistically + a debounced flush.
|
||||
@@ -59,7 +62,7 @@ export function useChannelPriority(opts: {
|
||||
}, optsRef.current.delay ?? 400),
|
||||
);
|
||||
},
|
||||
[flush],
|
||||
[flush, optsRef],
|
||||
);
|
||||
|
||||
// Read the LATEST value from the cache each step (not a render closure) so a hold-repeat
|
||||
@@ -68,7 +71,7 @@ export function useChannelPriority(opts: {
|
||||
(channelId: string, delta: number) => {
|
||||
setPriority(channelId, (optsRef.current.read(channelId) ?? 0) + delta);
|
||||
},
|
||||
[setPriority],
|
||||
[setPriority, optsRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffectEvent, useLayoutEffect, useState, type RefObject } from "react";
|
||||
|
||||
// The shared DOM-measure-and-fit core for a single-line chip lane that collapses its overflow into a
|
||||
// trailing "+N" pill. Owns the layout-effect + ResizeObserver + initial-measure wiring and reads the
|
||||
@@ -69,11 +69,10 @@ export function useChipOverflowFit<R>(args: {
|
||||
}): R {
|
||||
const { rowRef, chipsRef, pillRef, deps, decide, initial } = args;
|
||||
const [result, setResult] = useState<R>(initial);
|
||||
// Read `decide` fresh each run without making it an effect dependency: the effect re-runs on `deps`
|
||||
// Read `decide` fresh each run (an effect event) without making it an effect dependency: the effect re-runs on `deps`
|
||||
// (and resize), and a caller's decide closure is rebuilt every render — depending on it would
|
||||
// resubscribe the ResizeObserver on every render.
|
||||
const decideRef = useRef(decide);
|
||||
decideRef.current = decide;
|
||||
const decideNow = useEffectEvent(decide);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
@@ -85,7 +84,7 @@ export function useChipOverflowFit<R>(args: {
|
||||
const chips = (chipsRef.current ?? []).filter((c): c is HTMLElement => c != null);
|
||||
const lastChip = chips[chips.length - 1];
|
||||
setResult(
|
||||
decideRef.current({
|
||||
decideNow({
|
||||
avail: row.clientWidth,
|
||||
chipRights: chips.map(rightOf),
|
||||
pillW: pill.offsetWidth,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, type RefObject } from "react";
|
||||
import { useLayer } from "./layerStack";
|
||||
|
||||
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
|
||||
@@ -26,26 +26,22 @@ export function useDismiss(
|
||||
|
||||
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
|
||||
// that lands outside every registered element.
|
||||
// Both arguments are read through refs so the listener can stay subscribed once per open/close —
|
||||
// Both arguments are read through an effect event so the listener can stay subscribed once per open/close —
|
||||
// which is the point of the `[active]` dep list — without going STALE. The disabled dep list this
|
||||
// replaces asserted that onClose was "read fresh from this run's closure"; it was not. The closure
|
||||
// is built when `active` last flipped, so a caller that re-rendered with a different handler while
|
||||
// the popover stayed open kept getting the old one called (regression test in useDismiss.dom.test).
|
||||
// `refs` is the weaker case — the ref OBJECTS are stable, so a late-mounting node is seen either
|
||||
// way — but a caller that changes WHICH refs it passes while open was equally ignored.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const refsRef = useRef(refs);
|
||||
refsRef.current = refs;
|
||||
const onPress = useEffectEvent((e: MouseEvent) => {
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const current = refsRef.current;
|
||||
const list = Array.isArray(current) ? current : [current];
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onCloseRef.current();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => onPress(e);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
|
||||
|
||||
/** A ref that holds the latest COMMITTED `value`, for a stable callback that must still read fresh
|
||||
* props: an event handler, a timer, an API that takes its callbacks once. When only effects read the
|
||||
* value, use `useEffectEvent` instead.
|
||||
*
|
||||
* Written in a layout effect, never during render. A render-phase `ref.current = value` makes the
|
||||
* React Compiler skip the whole component (E32), and it is wrong under concurrent rendering anyway:
|
||||
* a render React throws away would still have overwritten the value. Read it outside render only. */
|
||||
export function useLatest<T>(value: T): RefObject<T> {
|
||||
const ref = useRef(value);
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** A callback with a STABLE identity that always runs the latest `fn` — for a handler handed to
|
||||
* something memoized (a column set, a menu model) that must not rebuild whenever the handler's
|
||||
* inputs change. Call it from events only, never during render: it runs the last COMMITTED `fn`. */
|
||||
export function useStableCallback<A extends unknown[], R>(
|
||||
fn: (...args: A) => R,
|
||||
): (...args: A) => R {
|
||||
const latest = useLatest(fn);
|
||||
return useCallback((...args: A) => latest.current(...args), [latest]);
|
||||
}
|
||||
@@ -74,20 +74,13 @@ export function usePref<T>(
|
||||
const { localKey, debounceMs, parse } = options;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Latest parse in a ref so the seed and the setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter). parse must be idempotent anyway.
|
||||
const parseRef = useRef(parse);
|
||||
parseRef.current = parse;
|
||||
const norm = useCallback((raw: unknown): T => {
|
||||
const p = parseRef.current;
|
||||
return p ? p(raw) : (raw as T);
|
||||
}, []);
|
||||
|
||||
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
|
||||
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
|
||||
// place in this file that had to be excused from the checker (it read `defaultValue` without
|
||||
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
// `parse` is read here only, once: the setter stores what it is given.
|
||||
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
|
||||
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
|
||||
if (server !== undefined) return norm(server);
|
||||
if (localKey) return norm(readAccount(localKey, defaultValue));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
@@ -10,12 +10,13 @@ export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||
// The caller typically passes an inline object literal (new identity every render); key on its
|
||||
// serialized value so the effect only fires when a param actually changes, not every render.
|
||||
const dep = JSON.stringify(updates);
|
||||
// The object itself is read through a ref, so the effect's one reactive input is the serialized
|
||||
// value — which is exactly what the dep list now says. The house pattern (see useChipOverflowFit's
|
||||
// decideRef, usePref's parseRef): a ref for the identity that churns, a primitive for the trigger.
|
||||
const updatesRef = useRef(updates);
|
||||
updatesRef.current = updates;
|
||||
// The object itself is read through an effect event, so the effect's one reactive input is the
|
||||
// serialized value — which is exactly what the dep list says: an effect event for the identity that
|
||||
// churns, a primitive for the trigger.
|
||||
const mirror = useEffectEvent(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
});
|
||||
useEffect(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updatesRef.current));
|
||||
mirror();
|
||||
}, [dep]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user