@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
536 lines (505 loc) • 27.1 kB
JavaScript
import * as React from 'react';
// `decodeHastSource` and `frameFallbackFromSpans` are already part of the
// always-loaded `useCode` shell (via `Pre`, `sourceLineCounts`,
// `useFileNavigation`, `useSourceEnhancing`). Passing them into the lazy
// transform engine keeps the engine chunk from statically pulling them (and
// `hastDecompress`) — they stay counted in this shell instead of being hoisted.
import { decodeHastSource } from "../pipeline/loadIsomorphicCodeVariant/decodeHastSource.mjs";
import { frameFallbackFromSpans } from "../pipeline/hastUtils/index.mjs";
import { getAvailableTransforms, getApplicableTransforms, transformHasCollapsePlaceholder } from "./useCodeUtils.mjs";
import { peekTransformEngine, loadTransformEngine, preloadTransformEngine, resetTransformEngineCache } from "./transformEngineCache.mjs";
import { useCodeContext } from "../CodeProvider/CodeContext.mjs";
import { usePreference } from "../usePreference/index.mjs";
import { useCoordinated } from "../useCoordinated/index.mjs";
import { useHighlightGate } from "./useHighlightGate.mjs";
import { useTransitionPhase } from "./useTransitionPhase.mjs";
// Stable identity for the hast helpers handed to the transform engine; both are
// module-level functions, so this never needs to change.
const transformRuntimeDeps = {
decode: decodeHastSource,
frameFallbackFromSpans
};
// The transform applier (`createTransformedFiles`, which pulls the `jsondiffpatch`
// chunk) is loaded on demand and cached in the light `./transformEngineCache`
// module — shared so `CodeHighlighter`'s speculative preload can prime it before
// the first transform-bearing block renders. Re-exported for tests and warming.
export { preloadTransformEngine, resetTransformEngineCache };
/**
* Minimum coordinator barrier wait used when `transformDelay` is unset
* or zero but a layout-shift-prone swap still needs to land on the
* same frame as peer demos. One animation frame at ~60fps so the
* coordinated paint feels instantaneous but every peer commits
* together — otherwise multiple sibling demos on the page would each
* trigger their own layout shift in sequence.
*/
const MIN_TRANSFORM_WAIT_MS = 16;
/**
* Time after an originator's announce by which all peers should have
* acked their preload. The barrier fires `onWaitingForPeers` at this
* boundary so consumers can surface a transient loading indicator,
* but does NOT force-commit — the wait continues up to
* `ultimateTimeoutMs` (10s) for slow peers.
*/
const TRANSFORM_GRACE_PERIOD_MS = 300;
/**
* Hook for managing code transforms and their application
* Uses the useLocalStorage hook for local storage persistence of transform preferences
*/
export function useTransformManagement({
context,
effectiveCode,
selectedVariantKey,
selectedVariant,
initialTransform,
transformDelay,
transformLayoutShift,
selectedFileName,
expanded
}) {
// Transform state - get available transforms from context or from the effective code data
const availableTransforms = React.useMemo(() => {
// First try to get from context
if (context?.availableTransforms && context.availableTransforms.length > 0) {
return context.availableTransforms;
}
// Otherwise, get from the effective code data using the utility function
return getAvailableTransforms(effectiveCode, selectedVariantKey);
}, [context?.availableTransforms, effectiveCode, selectedVariantKey]);
// Lazily-resolved transform engine (the `jsondiffpatch`-pulling applier).
// Initialized synchronously from the module cache so a warmed block (a later
// block on the page, or a test pre-warm) builds transforms in the same commit.
const {
transformEngineLoader
} = useCodeContext();
const [transformEngine, setTransformEngine] = React.useState(() => peekTransformEngine() ?? null);
// Resolve the engine once the block actually has transforms. Read-only /
// no-transform blocks skip this entirely and never pull the chunk. Adopts a
// cache another block already warmed, otherwise loads via the accessor
// (deduped page-wide) or the built-in import. Fail open.
React.useEffect(() => {
if (transformEngine || availableTransforms.length === 0) {
return undefined;
}
const warm = peekTransformEngine();
if (warm) {
// Adopt a sibling-warmed engine synchronously; the surrounding effect is a
// real async load. `peekTransformEngine()` is an impure read of a
// module-mutable cache, so this cannot be derived during render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTransformEngine(() => warm);
return undefined;
}
let cancelled = false;
// `preloadTransformEngine` caches the resolved applier (and DEBUG-logs a load
// failure), so we just read it back once it settles.
preloadTransformEngine(transformEngineLoader).then(() => {
if (cancelled) {
return;
}
const create = peekTransformEngine();
if (create) {
setTransformEngine(() => create);
}
});
return () => {
cancelled = true;
};
}, [transformEngine, availableTransforms.length, transformEngineLoader]);
// Broader set used to resolve a stored preference *and* to derive the
// localStorage key: includes rename-only transforms (manifest entries
// with `hasDelta: false`) so a user preference like 'js' still applies
// the `.ts` → `.js` rename even when the toggle is hidden because
// there's no source-level delta. We always compute this from
// `effectiveCode` — `context.availableTransforms` is the *visible*
// toggle list (filtered by `hasDelta`) and is intentionally not used
// here, otherwise rename-only entries would be dropped from
// resolution and the storage key would shift whenever a transform's
// visibility changed between sibling demos.
const applicableTransforms = React.useMemo(() => getApplicableTransforms(effectiveCode, selectedVariantKey), [effectiveCode, selectedVariantKey]);
// Coordinator key. Demos sharing the same applicable transform set
// belong to the same coordination group: a user click in one demo
// triggers a synchronized barrier across all of them. `null` only
// when there are no applicable transforms at all (nothing to swap),
// otherwise we always join — even single-transform demos benefit
// from coordinating with sibling instances that share the same
// toggle (e.g. an entire docs page of JS/TS-only demos).
const coordinatorKey = React.useMemo(() => applicableTransforms.length >= 1 ? [...applicableTransforms].sort().join(':') : null, [applicableTransforms]);
// Stable per-hook identity used by the coordinator to track which
// demos have acked the current barrier. `React.useId` gives us a
// unique-per-mount string without the impure `Math.random()` /
// `Date.now()` dance, and stays stable across re-renders.
const demoId = React.useId();
// Result of the off-critical-path `createTransformedFiles` call.
// Populated by `useCoordinated`'s `onCommit` so React batches the
// precomputed payload install with the committedValue flip into a
// single re-render in which the `transformedFiles` memo finds a
// matching cache entry and avoids re-running
// `createTransformedFiles` synchronously in the swap commit.
const [precomputed, setPrecomputed] = React.useState(null);
// Raw localStorage preference (string or empty/null encoding). The
// key is derived from `applicableTransforms` (the full set, includes
// rename-only entries) so demos with only rename-only transforms
// still participate in persistence and so a transform becoming
// rename-only doesn't move it to a different storage bucket.
const [storedValue, setStoredValue] = usePreference('transform', applicableTransforms.length === 1 ? applicableTransforms[0] : applicableTransforms,
// Don't use `initialTransform` as the fallback — localStorage
// should always take precedence. The initial-transform resolution
// happens below in `resolveTransform`.
() => null);
// Resolve a stored/initial value into a valid transform name (or null).
// Resolution uses `applicableTransforms` (which includes rename-only
// entries) so a stored preference can still apply a rename even when
// the toggle is hidden because no actual code delta exists.
const resolveTransform = React.useCallback(stored => {
if (stored !== null) {
if (stored === '') {
return null;
}
if (!applicableTransforms.includes(stored)) {
return null;
}
return stored;
}
if (initialTransform && applicableTransforms.includes(initialTransform)) {
return initialTransform;
}
return null;
}, [applicableTransforms, initialTransform]);
// While the highlighter has not yet produced parsed HAST for the
// transformed variant (`context.deferHighlight === true`), gate the
// localStorage-restored value behind the SSR-safe `null`. Without
// this gate, a refresh of a page whose stored preference selects a
// transform commits the swap during hydration — before
// `useCodeParsing` has run `parseCode` — so `<Pre>` paints the
// transformed source as unhighlighted plain text with a different
// frame structure, the collapse animation runs against that
// intermediate tree, and then `parseCode` resolves a frame or two
// later and the DOM rebuilds with full highlighting, producing a
// visible post-animation jump. Holding the underlying value at the
// SSR-safe default means `<Pre>` keeps rendering exactly what the
// server emitted until the highlighter is ready; the moment
// `deferHighlight` flips false, the receiver flow opens its barrier
// and the collapse animation plays once, against a fully-parsed
// target tree.
const effectiveStoredValue = context?.deferHighlight ? null : storedValue;
// Resolved view of the raw preference. This is the value
// `useCoordinated` sees as its "external source of truth"; when it
// changes from outside (peer broadcast, other tab, applicable
// transforms re-resolution, or the highlighter becoming ready) the
// hook's receiver flow opens a barrier so every demo on the page
// commits the swap together.
const resolvedStoredValue = React.useMemo(() => resolveTransform(effectiveStoredValue), [resolveTransform, effectiveStoredValue]);
// Wrap the storage setter so the `useCoordinated` tuple signature
// matches (`string | null` in, void out). `null` is encoded as `''`
// so the raw preference can distinguish "explicitly cleared"
// (empty string) from "never set / hydration placeholder" (null).
const setResolvedStoredValue = React.useCallback(next => {
setStoredValue(next === null ? '' : next);
}, [setStoredValue]);
// Stable tuple identity so `useCoordinated` doesn't churn its
// dependency arrays when this hook re-renders with the same value.
// The setter passed here is a no-op: storage writes are performed
// *eagerly* by `setSelectedTransformAsUser` (so user intent is
// persisted immediately on click and broadcast to peers in the same
// tick), not lazily on barrier commit. The engine sees the eager
// write echo back through `usePreference` and dedupes it via its
// `inFlightTargetRef` guard so the receiver flow doesn't double-fire.
const underlying = React.useMemo(() => [resolvedStoredValue, () => {}], [resolvedStoredValue]);
// Barrier wait length. Falls back to one frame when `transformDelay`
// isn't configured so peers still align on the same paint without
// making the click feel sluggish.
const hasDelay = typeof transformDelay === 'number' && transformDelay > 0;
// Latest committed transform — read by `causesLayoutShift` to
// classify the *outgoing* tree's collapse placeholders. Assigned
// after the `useCoordinated` call below so the ref always reflects
// the value the engine just committed.
const committedRef = React.useRef(resolvedStoredValue);
// Tracks the previous render's committed transform so we can decide
// the originator's `minWaitMs` synchronously inside
// `selectTransformDispatch`: leaving a non-null transform needs the
// pre-swap expand window, but `null → X` commits immediately.
const prevCommittedTransformRef = React.useRef(resolvedStoredValue);
// Latest props read by the engine's `causesLayoutShift` / `preload`
// / `onCommit` callbacks. Kept in a ref so those callbacks can be
// referentially stable (the engine captures them at announce time
// via the hook's internal callback ref, and a churn here would
// restart in-flight barriers).
const layoutShiftPropsRef = React.useRef({
selectedVariant,
transformLayoutShift,
selectedFileName,
expanded,
fallbacks: context?.fallbacks
});
// eslint-disable-next-line react-hooks/refs
layoutShiftPropsRef.current = {
selectedVariant,
transformLayoutShift,
selectedFileName,
expanded,
fallbacks: context?.fallbacks
};
// Plumb classifier props through `transformHasCollapsePlaceholder`
// on every render. The engine's `causesLayoutShift` callback is
// only invoked when a swap is actually announced, but consumers
// (and tests) rely on the classifier observing prop changes
// eagerly — both to validate plumbing and to surface any heavier
// diagnostic side effects the classifier might perform.
React.useMemo(() => transformHasCollapsePlaceholder(selectedVariant, resolvedStoredValue, {
mode: transformLayoutShift,
selectedFileName,
expanded
}), [selectedVariant, resolvedStoredValue, transformLayoutShift, selectedFileName, expanded]);
// A swap "causes layout shift" — and therefore needs the synchronous
// barrier path — when either the incoming or the outgoing tree
// carries `.collapse` placeholders that need to animate. Peers
// without layout shift get routed to the engine's lazy path and
// commit after `lazyMinWaitMs` (`effectiveDelay * 2` below) so their
// commit lands after the originator's full expand → swap → collapse
// window has played out.
const causesLayoutShift = React.useCallback(target => {
const props = layoutShiftPropsRef.current;
const incoming = transformHasCollapsePlaceholder(props.selectedVariant, target, {
mode: props.transformLayoutShift,
selectedFileName: props.selectedFileName,
expanded: props.expanded
});
if (incoming) {
return true;
}
return transformHasCollapsePlaceholder(props.selectedVariant, committedRef.current, {
mode: props.transformLayoutShift,
selectedFileName: props.selectedFileName,
expanded: props.expanded
});
}, []);
// Hold the originator's coordinator barrier open while the
// highlighter pipeline (sync `parseCode` + async `computeHastDeltas`)
// is still in flight. The receiver flow already masks the stored
// value through `effectiveStoredValue`, but an interactive click
// flows through `useCoordinated` directly and would otherwise commit
// after `transformDelay` even when `transformedCode` hasn't landed —
// painting the incoming tree from un-deltaed source then snapping to
// the deltaed version a frame later. See `useHighlightGate` for how
// the gate plumbs into the engine's `preload` slot.
const awaitHighlight = useHighlightGate(!!context?.deferHighlight);
// Off-critical-path build of the next file tree. Runs in the engine's
// `preload` slot so the originator's barrier holds open until the
// payload is ready, and the result is committed atomically with the
// value flip in `onCommit`.
//
// Synchronous fast path when the highlighter is ready: the engine
// fires the preload immediately (no microtask hop) so the result is
// available the moment `onCommit` runs inside the same `act(...)`
// callback as the timer fire.
//
// Async path when `context.deferHighlight === true`: we await the
// gate so the barrier's wait extends until the incoming tree can
// paint with its full transform deltas applied. The engine's
// `signal` is forwarded so a superseding announce can supersede the
// wait instead of leaking it.
const preload = React.useCallback((target, signal) => {
const buildResult = create => {
const props = layoutShiftPropsRef.current;
return {
variant: props.selectedVariant,
transform: target,
result: create(props.selectedVariant, target, transformRuntimeDeps, props.fallbacks)
};
};
// Resolve the engine: synchronously from the warm module cache, otherwise
// via the loader (defer-if-cold — the coordinator barrier holds the swap
// open until this promise resolves).
const wait = awaitHighlight(signal);
// Sync from the warm cache, else load (and cache) via the accessor.
const engine = loadTransformEngine(transformEngineLoader);
// Fully synchronous fast path: highlighter ready and engine already warm.
if (wait === null && typeof engine === 'function') {
return buildResult(engine);
}
return Promise.all([wait ?? Promise.resolve(), Promise.resolve(engine)]).then(([, create]) => buildResult(create));
}, [awaitHighlight, transformEngineLoader]);
const onCommit = React.useCallback((_target, preloaded) => {
if (preloaded) {
setPrecomputed(preloaded);
}
}, []);
const [delayedAppliedTransform, selectTransformDispatch, coordinationExtras] = useCoordinated(underlying, {
channelKey: coordinatorKey,
peerId: demoId,
causesLayoutShift,
preload,
onCommit,
// eslint-disable-next-line react-hooks/refs
minWaitMs: hasDelay && prevCommittedTransformRef.current !== null ? transformDelay : 0,
multiPeerExtraMinWaitMs: hasDelay ? 0 : MIN_TRANSFORM_WAIT_MS,
lazyMinWaitMs: hasDelay ? transformDelay : 0,
gracePeriodMs: TRANSFORM_GRACE_PERIOD_MS
});
// Keep the outgoing-tree probe in sync with whatever the engine just
// committed. Mutating a ref during render is safe — React tolerates
// it as long as the value derives deterministically from inputs of
// the current render.
// eslint-disable-next-line react-hooks/refs
committedRef.current = delayedAppliedTransform;
// eslint-disable-next-line react-hooks/refs
prevCommittedTransformRef.current = delayedAppliedTransform;
// User-facing "intent" value: updates synchronously on a local
// click (the engine sets `pendingValue` inside `runCoordination`
// before yielding) and on a peer broadcast (receiver flow likewise
// sets `pendingValue` synchronously when opening its barrier).
const selectedTransform = coordinationExtras.pendingValue;
// Surfaced by the engine only on the originator and only once the
// grace period has elapsed without convergence. `null` is a valid
// pending target (swap back to the un-transformed original);
// `undefined` means nothing is pending or we're still inside the
// grace window.
const pendingTransform = coordinationExtras.isWaitingForPeers ? coordinationExtras.pendingValue : undefined;
// No-op when called with the value already in flight / committed —
// otherwise the engine would open a fresh (redundant) barrier and
// re-announce, briefly toggling `isCoordinating` on peers. The
// storage write happens *before* the engine dispatch so user intent
// is broadcast to peer demos on the same tick as the click (every
// demo enters its expand → swap → collapse window together), even
// when this demo's own visible swap is gated by `transformDelay`.
//
// Validation differs from `resolveTransform`: an explicit `null`
// here means "user cleared the transform" — never re-resolved to
// `initialTransform` (which `resolveTransform` only consults for
// hydration of a never-set stored value).
const setSelectedTransformAsUser = React.useCallback(value => {
const resolved = value === null || applicableTransforms.includes(value) ? value : null;
if (resolved === selectedTransform) {
return;
}
// Start local coordination from the user action first so this demo
// is always treated as the originator (which drives waiting affordances
// like pendingTransform), then broadcast the persisted preference.
selectTransformDispatch(resolved);
setResolvedStoredValue(resolved);
}, [applicableTransforms, selectedTransform, setResolvedStoredValue, selectTransformDispatch]);
// Post-swap `data-transforming` (`'expanded'` paused → `'collapsing'`
// active) window. Fires whenever the committed transform swaps to a
// non-null value:
//
// - `null → A` when A has no `.collapse` placeholders the
// barrier's `minWaitMs` already played out; this
// window is the only animation hook on the
// incoming tree.
// - `A → B` the pre-swap (`'collapsed'` → `'expanding'`)
// window opened by the barrier covered the
// outgoing tree; the post-swap window adds a
// matching trailing animation hook, giving
// transform-to-transform a `2 × transformDelay`
// total window (expand → swap → collapse) so
// consumer CSS can animate both the outgoing
// and the incoming tree.
// - `A → null` does not arm the window — the trailing
// untransformed tree has nothing to enter-
// animate.
//
// Detected during render so the flag lands on the same paint as the
// new tree, then cleared after `transformDelay` ms.
const [postSwapWindowActive, setPostSwapWindowActive] = React.useState(false);
// Seed with a sentinel (`undefined`) rather than the committed value so a
// transform that is ALREADY applied on the first render — restored from a
// saved localStorage preference, or a demo `initialTransform` default — reads
// as a null→X swap and arms the post-swap window, animating the swap the same
// way a manual toggle does. A null initial transform compares equal-to-null
// below (`delayedAppliedTransform !== null` is false), so a no-transform mount
// still does not animate.
const [prevAppliedTransform, setPrevAppliedTransform] = React.useState(undefined);
if (prevAppliedTransform !== delayedAppliedTransform) {
setPrevAppliedTransform(delayedAppliedTransform);
if (delayedAppliedTransform !== null && hasDelay) {
setPostSwapWindowActive(true);
}
}
// The window only ever opens under `hasDelay` (line above), so an open window
// when `!hasDelay` means `hasDelay` flipped true→false — a derivable invariant,
// not a side-effect. Clear it during render so it lands on the same commit.
if (postSwapWindowActive && !hasDelay) {
setPostSwapWindowActive(false);
}
React.useEffect(() => {
if (!postSwapWindowActive) {
return undefined;
}
// `delayedAppliedTransform` is in the dep array so a fresh swap
// during an already-open window (A → B → C in rapid succession)
// re-arms the timer for the full `transformDelay` instead of
// inheriting whatever was left over from B's window.
const timerId = setTimeout(() => setPostSwapWindowActive(false), transformDelay);
return () => clearTimeout(timerId);
// `hasDelay` is intentionally not a dependency: the body never reads it (the
// `!hasDelay` window teardown is the render-time clear above).
}, [postSwapWindowActive, transformDelay, delayedAppliedTransform]);
// If both phases are technically eligible (e.g. user clicked a third
// target during a post-swap window), the pending pre-swap takes
// priority — the visible tree IS the just-applied one and it needs
// to expand out for the next swap. When `transformDelay` is not
// configured, no animation window is opening (any coordinator wait
// is the one-frame `MIN_TRANSFORM_WAIT_MS`, too short to animate)
// so the phase stays `null` even if `delayedAppliedTransform`
// briefly lags `selectedTransform`.
//
// Each phase enters a "paused" value first (`'collapsed'` for the
// pre-swap window, `'expanded'` for the post-swap window). The
// rendered `<Pre>` calls `notifyTransformTransitionReady` once it
// has painted the new tree at that paused value, flipping
// `transformTransitionReady` to `true` which advances the phase to
// the matching active value (`'expanding'` / `'collapsing'`). The
// readiness flag is keyed on the current paused window so each new
// swap starts with a fresh wait.
const transformTransitionWindowKey = `${String(delayedAppliedTransform)}|${String(selectedTransform)}|${postSwapWindowActive ? '1' : '0'}`;
const {
ready: transformTransitionReady,
notify: notifyTransformTransitionReady
} = useTransitionPhase(transformTransitionWindowKey);
const transformingPhase = (() => {
if (!hasDelay) {
return null;
}
// `null -> transform` should never expose a pre-swap 'expanding' frame.
// During hydration restore, `pendingValue` can flip to the stored
// transform one render before commit; treating that as 'expanding' causes
// a visible double animation (expand then collapse). Only non-null
// outgoing trees need the pre-swap expand phase.
if (delayedAppliedTransform !== selectedTransform && delayedAppliedTransform !== null) {
return transformTransitionReady ? 'expanding' : 'collapsed';
}
if (postSwapWindowActive) {
return transformTransitionReady ? 'collapsing' : 'expanded';
}
return null;
})();
// Memoize all transformed files based on the *committed* transform
// so the rendered tree stays put during the `transformDelay` window.
// Prefer the precomputed result captured by `useCoordinated`'s
// `onCommit` when its `(variant, transform)` keys match the values
// about to be rendered.
const transformedFiles = React.useMemo(() => {
if (precomputed && precomputed.variant === selectedVariant && precomputed.transform === delayedAppliedTransform) {
return precomputed.result;
}
// The engine hasn't resolved yet (cold). Defer this render's build — the
// resolve effect re-renders once it's ready, and the (un-transformed)
// original files render for the one intervening tick. `createTransformedFiles`
// returns `undefined` for a null transform anyway, so a no-transform block
// (engine never loaded) correctly yields `undefined` here.
if (!transformEngine) {
return undefined;
}
return transformEngine(selectedVariant, delayedAppliedTransform, transformRuntimeDeps, context?.fallbacks);
}, [precomputed, selectedVariant, delayedAppliedTransform, context?.fallbacks, transformEngine]);
const result = {
availableTransforms,
selectedTransform,
transformedFiles,
selectTransform: setSelectedTransformAsUser,
transformingPhase,
notifyTransformTransitionReady,
pendingTransform
};
return result;
}