UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

469 lines (454 loc) 20.7 kB
'use client'; import * as React from 'react'; import { registerPeer, reportValue, announceTarget, getBarrierAnnounceTime } from "./coordinatePreference.mjs"; import { whenLayoutShiftsSettled, layoutShiftsSettled } from "./layoutShiftGate.mjs"; /** * Options for {@link useCoordinated}. See `coordinatePreference` for * the underlying semantics; only React-specific behaviors are * documented here. */ let nextAutoPeerId = 0; function generatePeerId() { nextAutoPeerId += 1; return `peer-${nextAutoPeerId}`; } /** * Coordinate a piece of state across sibling component instances on * the same channel, so that visually disruptive value changes commit * in a single layout pass rather than independently. Designed as a * thin wrapper around any `useState`-shaped primitive (e.g. * `useLocalStorageState`, `usePreference`, plain `useState`). * * **Originator flow** — calling the returned `setValue`: * 1. `pendingValue` updates synchronously to the requested target * 2. The coordinator runs `preload` (per phase rules) and waits for * sibling peers (phase 1 only) * 3. When the barrier resolves, the underlying `setValue` is called * and this hook's visible `value` flips, so the swap is * consistent with the optional `onCommit` side-effect * * **Receiver flow** — when the underlying `value` changes from outside * (e.g. a storage event from another tab): * 1. `pendingValue` updates to match * 2. Coordination runs locally (this tab's peers run their own * phase-1 barrier) * 3. The visible `value` returned by this hook is held at the * previous value until the barrier resolves, then flips * * Pass `channelKey: null` to disable coordination — the hook becomes * a transparent pass-through of the underlying tuple. */ export function useCoordinated(underlying, options) { const [underlyingValue, setUnderlyingValue] = underlying; const { channelKey, peerId: explicitPeerId, causesLayoutShift, preload, onCommit, minWaitMs, multiPeerExtraMinWaitMs, lazyMinWaitMs, gracePeriodMs, ultimateTimeoutMs, animateDuringPreload = false, lazyCommitPriority = 'idle' } = options; // Stable peer id for the lifetime of the mounted component. Held in a ref so an // auto-generated fallback stays stable across renders, an explicit `peerId` prop // wins and resyncs on change, and an explicit id later removed keeps the last // explicit value. The registration effect keys off [channelKey, peerId]. /* eslint-disable react-hooks/refs -- deliberate stable-id ref: lazy init + explicit-prop resync during render; the ref is identity memory, never read for rendering output */ const peerIdRef = React.useRef(null); if (peerIdRef.current === null) { peerIdRef.current = explicitPeerId ?? generatePeerId(); } else if (explicitPeerId !== undefined && explicitPeerId !== peerIdRef.current) { peerIdRef.current = explicitPeerId; } const peerId = peerIdRef.current; /* eslint-enable react-hooks/refs */ // The visible (committed) value. Lags `underlyingValue` while a // phase-1 barrier is open in the receiver flow. const [committedValue, setCommittedValue] = React.useState(underlyingValue); // The latest target value (originator click or external change). const [pendingValue, setPendingValue] = React.useState(underlyingValue); const [isCoordinating, setIsCoordinating] = React.useState(false); const [isWaitingForPeers, setIsWaitingForPeers] = React.useState(false); // Keep latest callbacks in a ref so we don't re-register the peer // when the consumer passes inline functions. const callbacksRef = React.useRef({ causesLayoutShift, preload, onCommit, setUnderlyingValue }); /* eslint-disable react-hooks/refs -- latest-ref pattern: cache current callbacks for the once-registered peer and long-lived runCoordination closure; intentionally excluded from effect/useCallback deps to avoid re-registering on inline-function identity churn */ callbacksRef.current.causesLayoutShift = causesLayoutShift; callbacksRef.current.preload = preload; callbacksRef.current.onCommit = onCommit; callbacksRef.current.setUnderlyingValue = setUnderlyingValue; /* eslint-enable react-hooks/refs */ const timingRef = React.useRef({ minWaitMs, multiPeerExtraMinWaitMs, lazyMinWaitMs, gracePeriodMs, ultimateTimeoutMs, animateDuringPreload, lazyCommitPriority }); /* eslint-disable react-hooks/refs -- latest-ref pattern: cache current timing options for the once-registered peer and long-lived runCoordination closure; intentionally excluded from effect/useCallback deps to avoid re-registering on option identity churn */ timingRef.current.minWaitMs = minWaitMs; timingRef.current.multiPeerExtraMinWaitMs = multiPeerExtraMinWaitMs; timingRef.current.lazyMinWaitMs = lazyMinWaitMs; timingRef.current.gracePeriodMs = gracePeriodMs; timingRef.current.ultimateTimeoutMs = ultimateTimeoutMs; timingRef.current.animateDuringPreload = animateDuringPreload; timingRef.current.lazyCommitPriority = lazyCommitPriority; /* eslint-enable react-hooks/refs */ // In-flight handle so we can cancel/supersede. const handleRef = React.useRef(null); // Track the value we last asked the underlying primitive to take — // when we see it echoed back via `underlyingValue` we skip the // receiver flow so we don't double-coordinate our own writes. const lastWrittenRef = React.useRef({ has: false }); // The latest target the coordinator is working on; used to dedupe // re-entrant effect runs when `underlyingValue` changes mid-flight. const inFlightTargetRef = React.useRef({ has: false }); // Forward-declared ref so the peer registration's `onSiblingAnnounce` // can call the latest `runCoordination` without re-registering the // peer every time the callback identity changes. const runCoordinationRef = React.useRef(null); // Register / unregister this peer with the channel. React.useInsertionEffect(() => { if (channelKey === null) { return undefined; } const unregister = registerPeer(channelKey, peerId, target => { runCoordinationRef.current?.(target, false); }); reportValue(channelKey, peerId, committedValue); return () => { const handle = handleRef.current; handleRef.current = null; if (handle) { handle.cancel(); } inFlightTargetRef.current = { has: false }; unregister(); }; }, [channelKey, peerId]); // Tell the coordinator our latest committed value so it can skip // already-at-target peers when classifying barrier expectations. React.useInsertionEffect(() => { if (channelKey === null) { return; } reportValue(channelKey, peerId, committedValue); }, [channelKey, peerId, committedValue]); const runCoordination = React.useCallback((target, isOriginator) => { if (channelKey === null) { // Keep `inFlightTargetRef` in sync so two synchronous calls // like `setValue(p => p + 1); setValue(p => p + 1)` see the // freshest base — `setPendingValue` is async, so the second // updater would otherwise read the pre-render value and // collapse to a single increment. The receiver-flow effect // resets the ref on every external `underlyingValue` change, // and `runCoordination` overwrites it on every subsequent // call, so this won't leak across coordination cycles. inFlightTargetRef.current = { has: true, value: target }; setPendingValue(target); setCommittedValue(target); if (isOriginator) { lastWrittenRef.current = { has: true, value: target }; callbacksRef.current.setUnderlyingValue(target); } callbacksRef.current.onCommit?.(target, undefined); return; } // Dedupe: if we're already coordinating exactly this target // (e.g. a sibling-announce notification arrived after we // already kicked off our own receiver-flow announce, our own // originator-flow `announceTarget` call is still on the stack, // or the user clicked the same value twice in quick // succession), skip restarting the announcement. Without this // the second call would cancel our in-flight handle and we'd // lose our place in the barrier — or, when the dedupe also // fires re-entrantly mid-announce, we'd recurse forever. if (inFlightTargetRef.current.has && Object.is(inFlightTargetRef.current.value, target)) { return; } // Supersede any in-flight announcement. const previousHandle = handleRef.current; if (previousHandle) { previousHandle.cancel(); } inFlightTargetRef.current = { has: true, value: target }; // `pendingValue` is the user-facing "intent" signal and // always flips synchronously so toolbars / pickers stay // responsive on click. `isCoordinating` is what consumers // typically gate an animation on — when // `animateDuringPreload === false` we hold it until the // originator's preload settles so a CPU-bound preload // doesn't steal main-thread time from the animation that // follows. The coordinator yields to the browser before // invoking `preload`, so even sync preloads settle one // macrotask later; receivers flip in the same tick // regardless of the flag. setPendingValue(target); // Always clear stale waiting state from any previous // coordination cycle synchronously — otherwise a later // `onWaitingForPeers` (which fires from a timer relative to // the announce) could be overwritten when we flip // `isCoordinating`. setIsWaitingForPeers(false); const deferFlipForPreload = isOriginator && !timingRef.current.animateDuringPreload && callbacksRef.current.preload !== undefined; let coordinatingFlipped = false; const flipCoordinating = () => { if (coordinatingFlipped) { return; } coordinatingFlipped = true; setIsCoordinating(true); }; if (!deferFlipForPreload) { flipCoordinating(); } const userPreload = callbacksRef.current.preload; const flipWrappedPreload = deferFlipForPreload ? (preloadTarget, signal) => { // Wrap the user's preload so we flip `isCoordinating` // the instant it settles — whether sync or async — // but not before. Errors still flip; the engine logs // them and treats the result as `undefined`, and the // consumer expects the signal to converge regardless // of the preload outcome. let result; let threw; let didThrow = false; try { result = userPreload(preloadTarget, signal); } catch (err) { didThrow = true; threw = err; } const isThenable = !didThrow && result !== null && result !== undefined && typeof result.then === 'function'; if (!isThenable) { if (!signal.aborted) { flipCoordinating(); } if (didThrow) { throw threw; } return result; } return result.then(value => { if (!signal.aborted) { flipCoordinating(); } return value; }, err => { if (!signal.aborted) { flipCoordinating(); } throw err; }); } : userPreload; // Automatically hold the *commit* until the page's initial layout-shift // sources have settled (see `layoutShiftGate`), for layout-shifting // targets only. The consumer's preload still starts immediately and flips // `isCoordinating` on its own settle — the gate wait runs in parallel and // only delays the commit, so the first page-wide transform/variant change // lands as one unified update. A no-op when nothing has registered with // the gate (`whenLayoutShiftsSettled` returns `null`), so plain // `useCoordinated` consumers are unaffected. let wrappedPreload; if (userPreload) { wrappedPreload = (preloadTarget, signal) => { const inner = flipWrappedPreload(preloadTarget, signal); const gateWait = callbacksRef.current.causesLayoutShift(preloadTarget) ? whenLayoutShiftsSettled(signal) : null; if (gateWait === null) { return inner; } return Promise.all([gateWait, Promise.resolve(inner)]).then(([, result]) => result); }; } else if (!layoutShiftsSettled() && callbacksRef.current.causesLayoutShift(target)) { // No user preload, but the target shifts layout and the gate is still // closed: synthesize a preload that only awaits the gate so the commit // still holds until the page settles (the gate is otherwise consulted // only inside the user-preload wrapper, so without this branch a // layout-shifting peer with no preload would skip coordination entirely). // Once the gate has opened // this branch is skipped, so steady-state layout-shifting commits keep // the synchronous fast path. The gate wait rejects `AbortError` on // supersede, which propagates into the engine's `await preload(...)` // and is swallowed there because the signal is aborted — exactly like // the user-preload path above. wrappedPreload = (preloadTarget, signal) => { const gateWait = callbacksRef.current.causesLayoutShift(preloadTarget) ? whenLayoutShiftsSettled(signal) : null; if (gateWait === null) { return undefined; } return gateWait.then(() => undefined); }; } const handle = announceTarget(channelKey, peerId, target, { causesLayoutShift: callbacksRef.current.causesLayoutShift, preload: wrappedPreload, onCommit: (committedTarget, preloaded) => { // Side-effect first so consumers can install precomputed // payloads before the value flip becomes visible. callbacksRef.current.onCommit?.(committedTarget, preloaded); // If the engine force-resolved the barrier while our // deferred preload was still in flight (e.g. at // `ultimateTimeoutMs`), `flipCoordinating` may not have // run — flush it now so `isCoordinating` reaches the // true → false transition consumers expect at least // once per cycle. flipCoordinating(); if (isOriginator) { lastWrittenRef.current = { has: true, value: committedTarget }; callbacksRef.current.setUnderlyingValue(committedTarget); } setCommittedValue(committedTarget); // Clear coordination flags synchronously alongside the // value flip so the next render reflects both at once. setIsCoordinating(false); setIsWaitingForPeers(false); }, minWaitMs: timingRef.current.minWaitMs, multiPeerExtraMinWaitMs: timingRef.current.multiPeerExtraMinWaitMs, lazyMinWaitMs: timingRef.current.lazyMinWaitMs, gracePeriodMs: timingRef.current.gracePeriodMs, ultimateTimeoutMs: timingRef.current.ultimateTimeoutMs, lazyCommitPriority: timingRef.current.lazyCommitPriority, onWaitingForPeers: () => { setIsWaitingForPeers(true); }, isOriginator, // Non-originators (e.g. storage echoes) anchor to the // originator's announce time if a barrier is already open // so late joiners share the same wall-clock deadline. announceTime: !isOriginator && getBarrierAnnounceTime(channelKey, target) || Date.now() }); handleRef.current = handle; handle.settled.then(() => { if (handleRef.current === handle) { handleRef.current = null; inFlightTargetRef.current = { has: false }; setIsCoordinating(false); setIsWaitingForPeers(false); } }); }, [channelKey, peerId]); // Keep the ref pointed at the latest `runCoordination` so the // peer-registration callback (set once at mount) always invokes // the current closure. // eslint-disable-next-line react-hooks/refs -- forward-declared latest-ref: the once-registered peer callback must invoke the current runCoordination closure without re-registering runCoordinationRef.current = runCoordination; // Receiver flow: external `underlyingValue` change that we did not // originate. Trigger a local coordination so this tab's peers commit // together. Uses `useLayoutEffect` so the announcement lands in the // same synchronous flush as the originator's broadcast — otherwise // a sibling peer's `runCoordination` would slip past whatever // `setTimeout` cadence the test (or browser) is driving and miss // the originator's barrier window. React.useLayoutEffect(() => { if (channelKey === null) { // Coordination disabled: the hook is a transparent pass-through, // so the visible value / pending value are derived directly from // `underlyingValue` at the return rather than mirrored into state. // Only reset the in-flight ref here. inFlightTargetRef.current = { has: false }; return; } if (lastWrittenRef.current.has) { if (Object.is(lastWrittenRef.current.value, underlyingValue)) { // Echo of our own write — already coordinated. Treat the // sentinel as one-shot: consume it here so a *subsequent* // external write that happens to round-trip to the same // value (e.g. local→`b`, external→`c`, external→`b`) is // recognized as a genuine external change rather than // misclassified as another echo. lastWrittenRef.current = { has: false }; return; } // Sentinel is stale: the underlying moved to something other // than what we last wrote, so an earlier no-op originator // write (which produced no echo) must have left the sentinel // armed. Clear it now so a later external round-trip back to // that value isn't suppressed. lastWrittenRef.current = { has: false }; } if (handleRef.current && inFlightTargetRef.current.has && Object.is(inFlightTargetRef.current.value, underlyingValue) && Object.is(pendingValue, underlyingValue)) { // Already coordinating this target. return; } if (Object.is(committedValue, underlyingValue)) { // No external change to react to. We deliberately skip running // `preload` on mount when the underlying value already matches // our committed value: preload only needs to run when the value // changes out from under us without the consumer calling the // setter (e.g. a hydration write from `localStorage` or a // parent state update). After the consumer interacts with the // setter, the originator flow drives every cycle. return; } // eslint-disable-next-line react-hooks/set-state-in-effect -- receiver flow: runCoordination drives the coordination state machine in response to an external underlyingValue change; this is the genuine effect side-effect, not derivable during render runCoordination(underlyingValue, false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelKey, underlyingValue, runCoordination]); const coordinatedSetValue = React.useCallback(action => { // Functional updaters need to see the latest target — even // before React has re-rendered with the new `pendingValue` — // so that bursts like `setValue(p => p + 1); setValue(p => p + // 1)` compose to +2 instead of +1. `inFlightTargetRef` is // updated synchronously inside `runCoordination` before // `announceTarget` runs, so it's the freshest source. const base = inFlightTargetRef.current.has ? inFlightTargetRef.current.value : pendingValue; const next = typeof action === 'function' ? action(base) : action; runCoordination(next, true); }, [pendingValue, runCoordination]); const extras = React.useMemo(() => ({ pendingValue, isCoordinating, isWaitingForPeers }), [pendingValue, isCoordinating, isWaitingForPeers]); // When coordination is disabled the hook is a transparent // pass-through: derive the visible value, pending value, and inert // coordination flags straight from `underlyingValue` rather than // mirroring it into `committedValue` / `pendingValue` state. if (channelKey === null) { return [underlyingValue, coordinatedSetValue, { pendingValue: underlyingValue, isCoordinating: false, isWaitingForPeers: false }]; } return [committedValue, coordinatedSetValue, extras]; }