UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

643 lines (611 loc) 30.9 kB
import * as React from 'react'; import { usePreference } from "../usePreference/index.mjs"; import { useUrlHashState } from "../useUrlHashState/index.mjs"; import { useCoordinated } from "../useCoordinated/index.mjs"; import { useHighlightGate } from "./useHighlightGate.mjs"; import { useTransitionPhase } from "./useTransitionPhase.mjs"; import { isHashRelevantToDemo } from "./useFileNavigation.mjs"; import { toKebabCase } from "../pipeline/loaderUtils/toKebabCase.mjs"; import { variantHasLayoutShift } from "./sourceLineCounts.mjs"; /** * Parses the variant name from a URL hash * Hash formats: * - slug:file.tsx -> "Default" * - slug:variant:file.tsx -> "variant" * - slug:variant -> "variant" * @param urlHash - The URL hash (without '#') * @param mainSlug - The main slug for the demo (optional, used to determine if hash is relevant for file selection) * @param variantKeys - Available variant keys * @returns The variant name or null if not found/parseable */ function parseVariantFromHash(urlHash, mainSlug, variantKeys) { if (!urlHash) { return null; } const parts = urlHash.split(':'); // If there are 3 parts (slug:variant:file), the variant is in the middle if (parts.length === 3) { const variantPart = parts[1]; // Find matching variant key (case-insensitive kebab match) const matchingVariant = variantKeys.find(key => toKebabCase(key) === variantPart.toLowerCase()); return matchingVariant || null; } // If there are 2 parts, could be slug:variant or slug:file if (parts.length === 2) { const secondPart = parts[1]; // Try to match as a variant first const matchingVariant = variantKeys.find(key => toKebabCase(key) === secondPart.toLowerCase()); if (matchingVariant) { return matchingVariant; } // If no matching variant and it looks like a filename, assume Default if (secondPart.includes('.')) { return 'Default'; } } // Just the slug with no other parts, assume Default if (parts.length === 1) { return 'Default'; } return null; } /** * Resolve a stored / hash / initial preference into a valid variant * key. Priority: URL hash > localStorage > initialVariant > first * variant. Returns an empty string only when no variants are * available — callers should treat that as "no selection". */ function resolveVariantKey(hashVariant, storedValue, initialVariant, variantKeys) { if (hashVariant && variantKeys.includes(hashVariant)) { return hashVariant; } if (storedValue && variantKeys.includes(storedValue)) { return storedValue; } if (initialVariant && variantKeys.includes(initialVariant)) { return initialVariant; } return variantKeys[0] || ''; } /** * Hook for managing variant selection and providing variant-related data * Priority: URL hash > localStorage > initialVariant > first variant * When hash has a variant, it overrides localStorage and is saved to localStorage * * Wraps the selection in `useCoordinated` so sibling demos that share * the same variant set commit variant swaps together — preventing * staggered layout shifts when multiple demos on the page react to * the same preference change. */ /** * Minimum coordinator barrier wait used when `variantSwapDelay` 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. Mirrors the same constant in `useTransformManagement`. */ const MIN_VARIANT_WAIT_MS = 16; /** * Time after an originator's announce by which all peers should have * acked. Beyond this, the barrier surfaces `pendingVariantKey` so * consumers can render a transient loading indicator while continuing * to wait up to `ultimateTimeoutMs` (10s). Mirrors * `TRANSFORM_GRACE_PERIOD_MS`. */ const VARIANT_GRACE_PERIOD_MS = 300; export function useVariantSelection({ effectiveCode, initialVariant, variantType, mainSlug, saveHashVariantToLocalStorage = 'on-interaction', variantLayoutShift = 'selected', selectedFileName, expanded, variantSwapDelay, deferHighlight }) { // Get variant keys from effective code const variantKeys = React.useMemo(() => { return Object.keys(effectiveCode).filter(key => { const variant = effectiveCode[key]; return variant && typeof variant === 'object' && 'source' in variant; }); }, [effectiveCode]); // Get URL hash and parse variant from it const [urlHash, setUrlHash] = useUrlHashState(); const hashVariant = React.useMemo(() => parseVariantFromHash(urlHash, mainSlug, variantKeys), [urlHash, mainSlug, variantKeys]); // Use localStorage hook for variant persistence const [storedValue, setStoredValue] = usePreference('variant', variantType || variantKeys, () => { return null; }); // When a delay is configured, start from the boot-time value // (initialVariant or first variant), then adopt the stored value on // a later tick as a normal coordinated receiver swap. This allows // the initial→stored transition to open `data-transforming` windows // instead of resolving to the stored variant before first paint — // most visibly when the full content component replaces a loading // skeleton and needs to animate from the default variant to the // user's saved preference. // // The bootstrap is gated on the *stored* variant's source being // available as HAST (not a raw string). On a fresh mount, only the // default variant typically has source data; non-default variants // are lazy-loaded as URL refs and then parsed into HAST by the // highlighter pipeline. Firing the bootstrap before that lands // commits a swap to a variant whose `<Pre>` has no HAST to render, // producing the user-visible sequence: unhighlighted initial paint // → swap-and-animate against still-unhighlighted content → content // snaps to highlighted text mid-animation. Waiting for the stored // variant's HAST guarantees the receiver-flow animation plays once, // against a fully-highlighted target tree, so the visible order is // swap → highlight → animate. // // We also gate on the parent's `deferHighlight` flipping to `false`. // `deferHighlight` reflects the current variant's highlight pipeline // state (parsing + transforms). Without this wait the combobox // pending value flips to the stored variant as soon as its HAST is // available, but the receiver flow's `preload` then blocks on // `awaitHighlight` (which tracks the *current* variant's // `deferHighlight`). The visible result is a large gap where the // combo says "Tailwind" but the content is still CSS Modules with // `data-transforming` running against the stale tree. Waiting for // `deferHighlight=false` before flipping `allowStoredBootstrap` // keeps the combo and the content swap in lockstep — and, paired // with the unconditional `storedValueForResolve` gate below, means // the swap commit lands on an already-highlighted destination // instead of flashing the stored variant through its raw-source // fallback while its parse completes. const storedVariantSourceLoaded = React.useMemo(() => { // When there's no stored preference (or it's not a valid variant key), // the bootstrap doesn't change the resolved variant, so no wait needed. if (!storedValue || !variantKeys.includes(storedValue)) { return true; } const variantEntry = effectiveCode[storedValue]; if (!variantEntry || typeof variantEntry === 'string') { return false; } // Require HAST (not a raw string source) so the receiver-flow // animation runs against the already-highlighted target tree. return variantEntry.source != null && typeof variantEntry.source !== 'string'; }, [storedValue, variantKeys, effectiveCode]); // One-way latch: opens after the stored variant's HAST is // available and the parent highlighter is no longer deferring // highlights. Driven by an effect (not a render-time set-state) // so the first render always resolves to `initialVariant`; the // effect then flips the latch on a later tick, the resolved value // swings to `storedValue`, and the change drives the coordinated // receiver-flow swap (with its `data-transforming` animation). // Adopting the stored value synchronously on the first render // would skip the swap entirely — no animation, and // `pendingBootstrap` would never latch so `useCode` wouldn't // suppress highlight on the outgoing initial variant. const [allowStoredBootstrap, setAllowStoredBootstrap] = React.useState(false); React.useEffect(() => { if (!storedVariantSourceLoaded) { return; } if (deferHighlight) { return; } // Intentional later-tick latch: see the bootstrap-gate comment above. // Flipping this during render skips the receiver-flow swap animation and // prevents `pendingBootstrap` from ever latching. // eslint-disable-next-line react-hooks/set-state-in-effect setAllowStoredBootstrap(true); }, [storedVariantSourceLoaded, deferHighlight]); // Barrier wait length. Falls back to one frame when // `variantSwapDelay` isn't configured so peers still align on the // same paint without making the click feel sluggish. const hasDelay = typeof variantSwapDelay === 'number' && variantSwapDelay > 0; const effectiveSwapWindowMs = hasDelay ? variantSwapDelay : MIN_VARIANT_WAIT_MS; // Hold the resolved value on the initial variant until the // bootstrap gate (`allowStoredBootstrap`) releases whenever there // is a highlight pipeline to wait for — either because a // `variantSwapDelay` is configured (delayed swaps must always // settle on HAST) or because a `CodeHighlighter` parent is // publishing a `deferHighlight` signal (in which case the stored // variant's parse hasn't necessarily completed even though the // coordinator's `minWaitMs` is zero). Without this second clause, // the no-delay path would commit the swap on the very first // render and the stored variant would flash through its raw-source // fallback while its parse completed. When neither condition // applies (bare `useCode` consumers in tests / non-highlighted // contexts) we skip the gate so raw-string sources continue to // bootstrap synchronously. const shouldGateBootstrap = hasDelay || deferHighlight !== undefined; const storedValueForResolve = shouldGateBootstrap && !allowStoredBootstrap ? null : storedValue; // Resolved underlying value combining hash and localStorage (and // the initial/first-variant fallbacks). This is what `useCoordinated` // observes as its external source of truth — any change to hash or // storage opens a receiver-flow barrier so peer demos commit // together. const resolvedValue = React.useMemo(() => resolveVariantKey(hashVariant, storedValueForResolve, initialVariant, variantKeys), [hashVariant, storedValueForResolve, initialVariant, variantKeys]); // Stable underlying tuple. The setter is intentionally a no-op: // localStorage writes are performed *eagerly* by // `setSelectedVariantAsUser` (so user intent is persisted and // broadcast to peer demos on the same tick as the click), 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(() => [resolvedValue, () => {}], [resolvedValue]); // Coordinator key. Demos sharing the same variant set belong to the // same coordination group. Use the variant-type bucket when set so // unrelated variant sets that share a type (e.g. Yarn/Npm/Pnpm // installs) still coordinate even if their key list differs. const channelKey = React.useMemo(() => { // Single-variant demos have nothing to coordinate (no choice to // swap to) — skip the coordinator entirely so we don't trigger // its localStorage reads. Mirrors `usePreference`'s // single-element-array short-circuit. if (variantKeys.length < 2) { return null; } if (variantType) { return `variant:${variantType}`; } return `variant:${[...variantKeys].sort().join(':')}`; }, [variantKeys, variantType]); // 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(); // Latest props read by the engine's `causesLayoutShift` callback. // Kept in a ref so the callback itself can be referentially stable. const layoutShiftPropsRef = React.useRef({ effectiveCode, variantLayoutShift, selectedFileName, expanded }); // eslint-disable-next-line react-hooks/refs layoutShiftPropsRef.current = { effectiveCode, variantLayoutShift, selectedFileName, expanded }; // Latest committed variant key — read by `causesLayoutShift` to // compare against the swap target. Initialized to `''` (rather than // `resolvedValue`) because the post-`useCoordinated` assignment // below is the single source of truth: relying on the init argument // would leave the ref stale if `resolvedValue` later changed // outside of a commit. The empty string is a sentinel — // `variantHasLayoutShift` treats a falsy `from` key as "no shift". const committedRef = React.useRef(''); const causesLayoutShift = React.useCallback(target => { const props = layoutShiftPropsRef.current; return variantHasLayoutShift(props.effectiveCode, committedRef.current, target, { mode: props.variantLayoutShift, selectedFileName: props.selectedFileName, expanded: props.expanded }); }, []); // Track whether the most recent commit landed on `hashVariant` so // we can opt-in save to localStorage under `'on-load'` semantics. const lastStoredValueRef = React.useRef(storedValue); // eslint-disable-next-line react-hooks/refs lastStoredValueRef.current = storedValue; const lastHashVariantRef = React.useRef(hashVariant); // eslint-disable-next-line react-hooks/refs lastHashVariantRef.current = hashVariant; const onCommit = React.useCallback(target => { // Mirror the historical `'on-load'` behavior: when a swap // commits to whatever the hash currently points at, persist // that variant to localStorage so a subsequent visit without a // hash still lands on the same variant. if (saveHashVariantToLocalStorage === 'on-load' && lastHashVariantRef.current && lastHashVariantRef.current === target && target !== lastStoredValueRef.current) { setStoredValue(target); } }, [saveHashVariantToLocalStorage, setStoredValue]); // Tracks the previous render's committed variant so we can decide // the originator's `minWaitMs` synchronously inside // `selectVariantDispatch`: leaving a non-empty variant needs the // pre-swap expand window. Initialised to the resolved boot value // (mirroring `useTransformManagement`'s `prevCommittedTransformRef`) // so the very first user-driven dispatch already sees a non-empty // ref and applies `variantSwapDelay`. The empty-string sentinel is // kept as a fallback for the (rare) case where the variant list // resolves empty during boot. const prevCommittedVariantKeyRef = React.useRef(resolvedValue); // Hold the originator's coordinator barrier open while the // highlighter pipeline is still working on the incoming variant. // Without this, an interactive variant swap can commit after // `variantSwapDelay` even when the new variant's `parseCode` / // `computeHastDeltas` hasn't landed — the incoming `<Pre>` paints // from raw source then snaps to highlighted text a frame later. // See `useHighlightGate` for the gate plumbing. const awaitHighlight = useHighlightGate(!!deferHighlight); const preload = React.useCallback((_target, signal) => { const wait = awaitHighlight(signal); if (wait === null) { return undefined; } return wait; }, [awaitHighlight]); const [committedVariantKey, selectVariantDispatch, coordinationExtras] = useCoordinated(underlying, { channelKey, peerId: demoId, causesLayoutShift, preload, onCommit, // eslint-disable-next-line react-hooks/refs minWaitMs: hasDelay && prevCommittedVariantKeyRef.current !== '' ? variantSwapDelay : 0, multiPeerExtraMinWaitMs: hasDelay ? 0 : MIN_VARIANT_WAIT_MS, lazyMinWaitMs: hasDelay ? variantSwapDelay : 0, gracePeriodMs: VARIANT_GRACE_PERIOD_MS }); // eslint-disable-next-line react-hooks/refs prevCommittedVariantKeyRef.current = committedVariantKey; // 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 = committedVariantKey; // User-facing selected variant key. Prefer the pending value so UI // controls (tabs, dropdowns) react immediately to a click, even if // the engine is briefly holding the visible value back for a // coordinated barrier. const selectedVariantKey = coordinationExtras.pendingValue; // Track the initial committed variant so we can detect the very // first commit (whether driven by bootstrap or a user click that // races bootstrap). `pendingBootstrap` derives from this so callers // can suppress highlighting of the outgoing initial variant when a // stored-preference swap is known to be in flight — without it, // the initial variant briefly paints fully highlighted right at // the moment the combobox flips to the stored value, then flashes // through the animation against stale content before the incoming // tree commits. Latching on first commit (not on // `committedVariantKey === storedValue`) keeps the gate honest if // the user clicks during the bootstrap window: their click commits // a different variant and `pendingBootstrap` releases so the new // selection lights up normally. const [initialCommittedVariantKey, setInitialCommittedVariantKey] = React.useState(null); const [hasCommittedPastInitial, setHasCommittedPastInitial] = React.useState(false); React.useEffect(() => { if (hasCommittedPastInitial || !committedVariantKey) { return; } // Intentional later-tick latch: see the bootstrap-gate comment above. // This freezes the first non-empty committed value and only later detects // moving past it; moving the detection into render shifts exactly when // `pendingBootstrap` releases relative to paint, which the // highlight-suppression sequence was tuned around. /* eslint-disable react-hooks/set-state-in-effect */ if (initialCommittedVariantKey === null) { setInitialCommittedVariantKey(committedVariantKey); } else if (committedVariantKey !== initialCommittedVariantKey) { setHasCommittedPastInitial(true); } /* eslint-enable react-hooks/set-state-in-effect */ }, [committedVariantKey, hasCommittedPastInitial, initialCommittedVariantKey]); // Reset both bootstrap latches whenever the storage bucket // identity changes (the consumer swaps in a new lesson with a // different variant set, or `variantType` switches the // `usePreference` bucket). Without this, a second payload // inherits the first payload's "already bootstrapped" state and // its newly resolved stored preference is never adopted. // // The identity is derived from the bucket coordinates — the same // values `usePreference` keys off — rather than `effectiveCode`'s // object identity. `CodeHighlighterClient` rebuilds and republishes // the code object during ordinary parse / transform progress, so // keying on `effectiveCode` would re-arm the bootstrap path in // the middle of a user-driven swap (whose stored value is // persisted eagerly by `setSelectedVariantAsUser`), making an // interactive variant change look like an initial-mount stored // bootstrap and replaying it again. // // Tracking the previous identity in state (rather than a ref) // follows React's "adjusting state when a prop changes" pattern // so the reset stays a synchronous render-time decision without // violating the refs-during-render rule. const bootstrapIdentity = React.useMemo(() => `${variantType ?? ''}\u0000${[...variantKeys].sort().join('\u0001')}`, [variantType, variantKeys]); const [prevBootstrapIdentity, setPrevBootstrapIdentity] = React.useState(bootstrapIdentity); if (prevBootstrapIdentity !== bootstrapIdentity) { setPrevBootstrapIdentity(bootstrapIdentity); // Reset to `false` so the effect re-runs and the resolved value // swings from initial → stored on a later tick. Setting it true // synchronously here would skip the receiver-flow swap animation // for the new bucket — same regression class as bootstrapping // synchronously on first render. setAllowStoredBootstrap(false); setHasCommittedPastInitial(false); setInitialCommittedVariantKey(null); } // A stored-preference bootstrap is only actually pending when no // higher-precedence source (the URL hash) is already winning. When // the hash takes precedence, the resolved value matches the hash // forever and no bootstrap swap will ever fire — gating only on // `storedValue !== committedVariantKey` here would leave // `pendingBootstrap` latched forever, which `useCode` translates // into "never highlight". The hash precedence guard keeps // permalinked / hash-selected demos highlighting normally even when // the user's saved preference points at a different variant. const hashOverridesStorage = !!hashVariant && variantKeys.includes(hashVariant); const pendingBootstrap = !hasCommittedPastInitial && !hashOverridesStorage && !!storedValue && variantKeys.includes(storedValue) && storedValue !== committedVariantKey; // User setter: persists to localStorage (and clears any relevant // URL hash) before dispatching the coordinator so peer demos // observe the new preference on the same tick as the click. // Validation differs from `resolveVariantKey`: an explicit `null` // means "fall back to the first variant" — never re-resolved to // `initialVariant` (which is only consulted for the // never-set-storage hydration path). const setSelectedVariantAsUser = React.useCallback(value => { const resolved = typeof value === 'function' ? value(selectedVariantKey) : value; const effectiveValue = resolved ?? variantKeys[0]; if (!effectiveValue || !variantKeys.includes(effectiveValue)) { return; } if (effectiveValue === selectedVariantKey && effectiveValue === committedVariantKey) { return; } // Clear hash first so the receiver flow doesn't observe a // stale hash → variant mapping after the storage write echoes // back through `usePreference`. Only clear when the current // hash is one this demo cares about. if (urlHash && mainSlug && isHashRelevantToDemo(urlHash, mainSlug)) { setUrlHash(null); } // Start local coordination from the user action first so this // demo is always treated as the originator (matching the // pattern in `useTransformManagement`), then persist. selectVariantDispatch(effectiveValue); setStoredValue(effectiveValue); }, [selectedVariantKey, committedVariantKey, variantKeys, urlHash, mainSlug, setUrlHash, selectVariantDispatch, setStoredValue]); // Programmatic setter: doesn't save to localStorage and doesn't // clear the hash. Used for hash-driven changes routed through // `useFileNavigation`. const setSelectedVariantProgrammatic = React.useCallback(value => { const resolved = typeof value === 'function' ? value(selectedVariantKey) : value; if (!variantKeys.includes(resolved)) { return; } if (resolved === selectedVariantKey && resolved === committedVariantKey) { return; } selectVariantDispatch(resolved); }, [selectedVariantKey, committedVariantKey, variantKeys, selectVariantDispatch]); const selectedVariant = React.useMemo(() => { const variant = effectiveCode[selectedVariantKey]; if (variant && typeof variant === 'object' && 'source' in variant) { return variant; } return null; }, [effectiveCode, selectedVariantKey]); // Variant resolved from the *committed* key — lags `selectedVariant` // by `variantSwapDelay` ms when a delay is configured. Used by the // renderer so the outgoing tree stays put for the pre-swap window. const committedVariant = React.useMemo(() => { if (committedVariantKey === selectedVariantKey) { return selectedVariant; } const variant = effectiveCode[committedVariantKey]; if (variant && typeof variant === 'object' && 'source' in variant) { return variant; } return null; }, [effectiveCode, committedVariantKey, selectedVariantKey, selectedVariant]); // Post-swap `data-transforming="expanded"`/`"collapsing"` window. Mirrors the // equivalent in `useTransformManagement`: fires after the engine // commits a swap so the incoming tree has a chance to enter-animate // any bridge `.collapse` placeholder appended by `<Pre>`. Only // armed when `variantSwapDelay` is configured. // // `collapseSourceVariantKey` captures the variant we just left at // the moment of commit so the post-swap window has a stable bridge // target even if `selectedVariantKey` keeps changing. const [postSwapWindowActive, setPostSwapWindowActive] = React.useState(false); const [collapseSourceVariantKey, setCollapseSourceVariantKey] = React.useState(null); const [prevAppliedVariant, setPrevAppliedVariant] = React.useState(committedVariantKey); if (prevAppliedVariant !== committedVariantKey) { if (hasDelay && prevAppliedVariant !== '') { setPostSwapWindowActive(true); setCollapseSourceVariantKey(prevAppliedVariant); } setPrevAppliedVariant(committedVariantKey); } // Tear down a stale window synchronously at render time: no animation // window should exist without a delay, so a window left open after // `hasDelay` flips to false is cleared a tick earlier than an effect would, // with no animation to disrupt (there is no delay). if (postSwapWindowActive && !hasDelay) { setPostSwapWindowActive(false); setCollapseSourceVariantKey(null); } React.useEffect(() => { if (!postSwapWindowActive) { return undefined; } const timerId = setTimeout(() => { setPostSwapWindowActive(false); setCollapseSourceVariantKey(null); }, effectiveSwapWindowMs); return () => clearTimeout(timerId); }, [postSwapWindowActive, effectiveSwapWindowMs, committedVariantKey]); // If both phases are technically eligible, 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 `variantSwapDelay` // isn't configured, no animation window is opening (the coordinator // wait is the one-frame `MIN_VARIANT_WAIT_MS`, too short to // animate) so the phase stays `null`. // // Each phase enters a "paused" value first (`'collapsed'` for the // pre-swap window, `'expanded'` for the post-swap window). The // rendered `<Pre>` calls `notifyVariantTransitionReady` once it has // painted the new tree at that paused value, flipping // `variantTransitionReady` to `true` which advances the phase to // the matching active value (`'expanding'` / `'collapsing'`). The // readiness flag is keyed on `(committedVariantKey, // selectedVariantKey)` so each new paused window starts with a // fresh wait. const variantTransitionWindowKey = `${committedVariantKey}|${selectedVariantKey}|${postSwapWindowActive ? '1' : '0'}`; const { ready: variantTransitionReady, notify: notifyVariantTransitionReady } = useTransitionPhase(variantTransitionWindowKey); const variantSwappingPhase = (() => { if (!hasDelay) { return null; } if (committedVariantKey !== selectedVariantKey) { return variantTransitionReady ? 'expanding' : 'collapsed'; } if (postSwapWindowActive) { return variantTransitionReady ? 'collapsing' : 'expanded'; } return null; })(); const swapPartnerVariantKey = (() => { if (variantSwappingPhase === 'collapsed' || variantSwappingPhase === 'expanding') { return selectedVariantKey || null; } if (variantSwappingPhase === 'expanded' || variantSwappingPhase === 'collapsing') { return collapseSourceVariantKey; } return null; })(); const pendingVariantKey = coordinationExtras.isWaitingForPeers ? coordinationExtras.pendingValue : undefined; // Safety check: if the selected variant truly disappears from the // code map (e.g. variant keys re-resolved), fall back to the first // variant. We deliberately *don't* trip on a key whose entry exists // but is still a lazy placeholder (string / partial object without // `source`): during incremental loading the variant key is valid, // the source just hasn't arrived yet, and bouncing through the // coordinator here would round-trip the selection // stored → first-variant → stored (the receiver flow re-resolves // back to the underlying value the moment the placeholder swaps // for a real `VariantCode`). `variantKeys` itself only includes // fully-loaded variants, so it's not a reliable signal here. const keyExistsInCode = selectedVariantKey ? Object.prototype.hasOwnProperty.call(effectiveCode, selectedVariantKey) : false; React.useEffect(() => { if (!keyExistsInCode && variantKeys.length > 0) { setSelectedVariantProgrammatic(variantKeys[0]); } }, [keyExistsInCode, variantKeys, setSelectedVariantProgrammatic]); // Function to save variant to localStorage (used for on-interaction mode) const saveVariantToLocalStorage = React.useCallback(variant => { if (saveHashVariantToLocalStorage === 'on-interaction' && variant !== storedValue) { setStoredValue(variant); } }, [saveHashVariantToLocalStorage, storedValue, setStoredValue]); return { variantKeys, selectedVariantKey, selectedVariant, committedVariantKey, committedVariant, variantSwappingPhase, swapPartnerVariantKey, pendingVariantKey, pendingBootstrap, notifyVariantTransitionReady, selectVariant: setSelectedVariantAsUser, selectVariantProgrammatic: setSelectedVariantProgrammatic, saveVariantToLocalStorage, hashVariant }; }