@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
406 lines (383 loc) • 19.9 kB
JavaScript
import * as React from 'react';
import { useCodeHighlighterContextOptional } from "../CodeHighlighter/CodeHighlighterContext.mjs";
import { useCodeContext } from "../CodeProvider/CodeContext.mjs";
import { useControlledCode } from "../CodeControllerContext/index.mjs";
import { extractNameAndSlugFromUrl } from "../pipeline/loaderUtils/index.mjs";
import { useVariantSelection } from "./useVariantSelection.mjs";
import { useTransformManagement } from "./useTransformManagement.mjs";
import { useFileNavigation } from "./useFileNavigation.mjs";
import { useUIState } from "./useUIState.mjs";
import { useCopyFunctionality } from "./useCopyFunctionality.mjs";
import { useSourceEditing } from "./useSourceEditing.mjs";
import { findCollapseInFocusTransforms, shouldHighlightForRender } from "./useCodeUtils.mjs";
import { findVariantFocusedLinesMismatches } from "./sourceLineCounts.mjs";
export function useCode(contentProps, opts) {
const {
copy: copyOpts,
initialVariant,
initialTransform,
preClassName,
fileHashMode = 'remove-hash',
saveHashVariantToLocalStorage = 'on-interaction',
sourceEnhancers,
disabled,
onExpand,
transformDelay,
transformLayoutShift = 'selected',
strictCollapseInFocus = false,
variantLayoutShift = 'selected',
variantSwapDelay,
strictMatchingVariantFocusedLines = false
} = opts || {};
// Safely try to get context values - will be undefined if not in context
const context = useCodeHighlighterContextOptional();
const codeContext = useCodeContext();
const controllerContext = useControlledCode();
// Merge enhancers from CodeProvider, CodeControllerContext, and useCode opts.
// Provider enhancers run first so they match the order applied by
// `loadPrecomputedCodeHighlighter` on the server, then controller and
// per-call enhancers layer on top. This lets a single `<CodeProvider>`
// configure the baseline (e.g., `@highlight` / `@focus` framing) while
// individual `useCode` callers add demo-specific extras without losing the
// shared defaults.
// Hoist the optional controller member into a stable local so the memo's
// inferred dependency matches the source dependency. `useControlledCode()`
// always returns a fresh object, so `controllerContext` is never null but
// changes identity every render; depending on the narrowed value keeps the
// memo correct (and lets the compiler preserve the manual memoization).
const controllerEnhancers = controllerContext?.sourceEnhancers;
const mergedEnhancers = React.useMemo(() => {
const enhancers = [];
if (codeContext.sourceEnhancers) {
enhancers.push(...codeContext.sourceEnhancers);
}
if (controllerEnhancers) {
enhancers.push(...controllerEnhancers);
}
if (sourceEnhancers) {
enhancers.push(...sourceEnhancers);
}
return enhancers.length > 0 ? enhancers : undefined;
}, [codeContext.sourceEnhancers, controllerEnhancers, sourceEnhancers]);
// Get the effective code - context overrides contentProps if available
const effectiveCode = React.useMemo(() => {
return context?.code || contentProps.code || {};
}, [context?.code, contentProps.code]);
// Opt-in development-time assertion: throw if any transform's
// `.collapse` placeholder would land inside the focus region. The
// check is purely a lookup against precomputed manifest flags (no
// tree walking) so it is cheap to run on every render; the memo
// ensures the actual scan only re-runs when `effectiveCode` changes.
// Fail-fast in render so demo authors notice the problem the first
// time they load the page instead of debugging a missing animation.
const collapseInFocusOffenders = React.useMemo(() => strictCollapseInFocus ? findCollapseInFocusTransforms(effectiveCode) : null, [strictCollapseInFocus, effectiveCode]);
if (collapseInFocusOffenders && collapseInFocusOffenders.length > 0) {
const first = collapseInFocusOffenders[0];
const extraCount = collapseInFocusOffenders.length - 1;
const suffix = extraCount > 0 ? ` (${extraCount} more offender(s) suppressed).` : `.`;
throw new Error(`[useCode] strictCollapseInFocus is enabled and transform "${first.transformKey}" on variant "${first.variantName}" file "${first.fileName}" introduces a .collapse placeholder inside the visible focus region. Narrow the focused area (e.g. tighten @focus/@padding markers or shrink the transform's edit range) so the placeholder lands outside the initially-visible window${suffix}`);
}
// Opt-in development-time assertion: throw if any two variants
// declare a file with the same name but disagree on
// `focusedLines`. Cheap precomputed-metadata lookup — the memo
// ensures the actual scan only re-runs when `effectiveCode`
// changes. Fail-fast in render so demo authors notice the problem
// the first time they load the page.
const variantFocusedLinesMismatches = React.useMemo(() => strictMatchingVariantFocusedLines ? findVariantFocusedLinesMismatches(effectiveCode) : null, [strictMatchingVariantFocusedLines, effectiveCode]);
if (variantFocusedLinesMismatches && variantFocusedLinesMismatches.length > 0) {
const first = variantFocusedLinesMismatches[0];
const extraCount = variantFocusedLinesMismatches.length - 1;
const suffix = extraCount > 0 ? ` (${extraCount} more mismatch(es) suppressed).` : `.`;
throw new Error(`[useCode] strictMatchingVariantFocusedLines is enabled and file "${first.fileName}" has ${first.focusedLinesA} focused line(s) in variant "${first.variantA}" but ${first.focusedLinesB} focused line(s) in variant "${first.variantB}". Align the @focus/@padding markers across variants so the collapsed window matches${suffix}`);
}
// Dev-only sanity check: `strictMatchingVariantFocusedLines` only
// protects against coordinated-barrier risk while the block is
// collapsed under `variantLayoutShift: 'focus'`. Enabling it in
// any other mode produces throws that don't correspond to a real
// layout-shift hazard, so warn the author once per render.
if (process.env.NODE_ENV !== 'production' && strictMatchingVariantFocusedLines && variantLayoutShift !== 'focus') {
console.warn(`[useCode] strictMatchingVariantFocusedLines is enabled but variantLayoutShift is "${variantLayoutShift}". The strict check only guards coordinated-barrier swaps under 'focus' mode; consider setting variantLayoutShift: 'focus' or disabling strictMatchingVariantFocusedLines.`);
}
// Memoize userProps with auto-generated name and slug if missing
const userProps = React.useMemo(() => {
// Extract only the user-defined properties (T) from contentProps
const {
name: contentName,
slug: contentSlug,
code,
components,
url: contentUrl,
// `collapseToEmpty` / `initialExpanded` are render-time display flags, not
// user-facing props — strip them (rest siblings) so they don't leak into
// the demo's props.
collapseToEmpty: collapseToEmptyContentProp,
initialExpanded: initialExpandedContentProp,
...userDefinedProps
} = contentProps;
// Get URL from context first, then fall back to contentProps
const effectiveUrl = context?.url || contentUrl;
let name = contentName;
let slug = contentSlug;
// Generate name and slug from URL if they're missing and we have a URL
if ((!name || !slug) && effectiveUrl) {
try {
const generated = extractNameAndSlugFromUrl(effectiveUrl);
name = name || generated.name;
slug = slug || generated.slug;
} catch {
// If URL parsing fails, keep the original values (which might be undefined)
}
}
return {
...userDefinedProps,
name,
slug
};
}, [contentProps, context?.url]);
// Resolve the render-time display flags. They must come from `contentProps`
// (threaded by the demo factory / `CodeHighlighter` / code transforms) rather
// than `useCode` opts: the loading fallback derives its own copy from the same
// `contentProps`, so a per-call opt would let the live render and the fallback
// disagree.
const collapseToEmpty = contentProps.collapseToEmpty === true;
const initialExpanded = contentProps.initialExpanded === true;
// Sub-hook: UI State Management (needs slug to check for relevant hash)
const uiState = useUIState({
initialExpanded,
mainSlug: userProps.slug
});
// Lift `selectedFileName` state out of `useFileNavigation` so
// `useTransformManagement` *and* `useVariantSelection` can read it
// (selected-file-scoped `transformLayoutShift` /
// `variantLayoutShift` modes). `useFileNavigation` consumes the
// value + setter as controlled props. Initial value is resolved
// below once `useVariantSelection` has reported the initial
// variant.
const [selectedFileNameState, setSelectedFileNameState] = React.useState(undefined);
// Sub-hook: Variant Selection
const variantSelection = useVariantSelection({
effectiveCode,
initialVariant,
variantType: contentProps.variantType,
mainSlug: userProps.slug,
saveHashVariantToLocalStorage,
variantLayoutShift,
selectedFileName: selectedFileNameState,
expanded: uiState.expanded,
variantSwapDelay,
deferHighlight: context?.deferHighlight
});
// Seed the selected file name from the variant's main file the
// first time the variant resolves. Subsequent file selections come
// through `useFileNavigation`'s controlled setter. Set-state during
// render triggers one extra render on first mount; we accept that
// cost because the alternative (lazy `useState` initializer)
// requires resolving the variant key here, which depends on
// `useUrlHashState` / `usePreference` hooks that already live
// inside `useVariantSelection`. Duplicating them at this level
// would be worse than the extra render.
if (selectedFileNameState === undefined && variantSelection.selectedVariant?.fileName) {
setSelectedFileNameState(variantSelection.selectedVariant.fileName);
}
// Defer the outgoing `<Pre>` from rendering highlighted spans while
// a stored-preference bootstrap swap is known to be coming. See
// `shouldHighlightForRender` for the full rationale, including the
// `highlightAfter === 'init'` bypass that prevents a visible flash
// of unhighlighted code on first-paint variant swaps.
const shouldHighlight = shouldHighlightForRender({
deferHighlight: context?.deferHighlight,
highlightReady: context?.highlightReady,
pendingBootstrap: variantSelection.pendingBootstrap,
highlightAfter: context?.highlightAfter
});
// The rendered tree should reflect the *committed* variant so the
// outgoing `<Pre>` stays put during `variantSwapDelay`. When no
// delay is configured these are always equal to `selectedVariant` /
// `selectedVariantKey`. Falling back to the pending value (rather
// than `null`) keeps the boot path — before the coordinator has
// committed for the first time — rendering the freshly-resolved
// variant instead of nothing.
const renderedVariant = variantSelection.committedVariant ?? variantSelection.selectedVariant;
const renderedVariantKey = variantSelection.committedVariantKey || variantSelection.selectedVariantKey;
// Sub-hook: Transform Management
const transformManagement = useTransformManagement({
context,
effectiveCode,
selectedVariantKey: renderedVariantKey,
selectedVariant: renderedVariant,
initialTransform,
transformDelay,
transformLayoutShift,
selectedFileName: selectedFileNameState,
expanded: uiState.expanded
});
// Sub-hook: Source Editing
const sourceEditing = useSourceEditing({
context,
selectedVariantKey: renderedVariantKey,
effectiveCode,
selectedVariant: renderedVariant,
disabled
});
// Combine the two animation phases into a single `transforming`
// attribute for `<Pre>`. Both phases share the `data-transforming`
// attribute and the `.collapse` placeholder bridge — the only
// difference is which delta drives the bridge. When both are
// simultaneously eligible (rare — a transform swap mid-variant-swap
// window) the variant phase takes precedence because the rendered
// tree just swapped variants and that's the larger visual change.
const transforming = variantSelection.variantSwappingPhase ?? transformManagement.transformingPhase;
// Route `<Pre>`'s readiness callback to whichever phase source owns
// the current animation window. Each source flips its own paused →
// active transition independently; we just forward the signal.
const variantPhaseActive = variantSelection.variantSwappingPhase !== null;
const notifyVariantTransitionReady = variantSelection.notifyVariantTransitionReady;
const notifyTransformTransitionReady = transformManagement.notifyTransformTransitionReady;
const onPreTransitionReady = React.useCallback(() => {
if (variantPhaseActive) {
notifyVariantTransitionReady();
} else {
notifyTransformTransitionReady();
}
}, [variantPhaseActive, notifyVariantTransitionReady, notifyTransformTransitionReady]);
// Defer `expand()` while a variant or transform swap is in flight,
// or while the currently-displayed variant's source is still being
// highlighted. Callers that pair `selectVariant(...)` /
// `selectTransform(...)` with `expand()` in the same tick (e.g.
// "show source of variant X" or "switch to JS then expand"
// affordances) would otherwise flip `expanded` mid-animation: the
// bridge `.collapse` placeholder switches metric (`focus` →
// `total`) and the previously-hidden rows pop in before the swap
// commits, producing a visible jump. When no `variantSwapDelay`
// is configured the swap commits synchronously, but the new
// variant's `parsedCode` is still computed asynchronously — the
// `deferHighlight` flag published by `CodeHighlighterClient`
// (true while `waitingForParsedCode`) keeps the gate engaged
// through that window too.
//
// `expand()` always queues through a `pendingExpand` state flag;
// a passive effect resolves it on every render where the composed
// `transforming` phase is `null` and `deferHighlight` is falsy
// (i.e. neither a variant/transform swap nor an async re-highlight
// is in flight). Using state (not a ref) keeps the drain reactive:
// a synchronous `expand()` triggers a render, the effect runs, and
// `setExpanded(true)` flushes in the same React batch — preserving
// the "expand is synchronous" semantics for the common case while
// naturally waiting on in-flight swaps. `setExpanded` stays direct
// so explicit controlled-state writes remain synchronous regardless
// of swap phase.
const setExpanded = uiState.setExpanded;
const swapInFlight = transforming !== null || !!context?.deferHighlight;
const [pendingExpand, setPendingExpand] = React.useState(false);
// Keep the latest `onExpand` in a ref so the stable `expand` callback below
// can call it without changing identity (it is forwarded down to `<Pre>`).
const onExpandRef = React.useRef(onExpand);
React.useLayoutEffect(() => {
onExpandRef.current = onExpand;
});
const expand = React.useCallback(() => {
// Notify the host synchronously, while the block is still collapsed, so it
// can capture the pre-expansion layout and engage a scroll anchor (the
// expansion itself is deferred below via `pendingExpand`). This mirrors the
// timing of clicking the expand toggle, where the host anchors the scroll
// before the layout changes.
onExpandRef.current?.();
setPendingExpand(true);
}, []);
React.useEffect(() => {
if (pendingExpand && !swapInFlight) {
/* eslint-disable react-hooks/set-state-in-effect -- intentional queue drain: commit deferred expand only after in-flight swaps settle (swapInFlight transitions false on a later render); see comment above re: flicker-avoidance and same-batch synchronous-expand semantics */
setPendingExpand(false);
setExpanded(true);
/* eslint-enable react-hooks/set-state-in-effect */
}
}, [pendingExpand, swapInFlight, setExpanded]);
// Partner variant whose per-file line counts feed `<Pre>`'s bridge
// computation. `null` when no variant swap is in flight (the bridge
// collapses to a no-op inside `<Pre>` either way; this lookup is a
// performance shortcut so we don't read the entire variant on every
// render).
const swapPartnerVariant = React.useMemo(() => {
if (!variantSelection.swapPartnerVariantKey) {
return null;
}
const variant = effectiveCode[variantSelection.swapPartnerVariantKey];
if (variant && typeof variant === 'object' && 'source' in variant) {
return variant;
}
return null;
}, [effectiveCode, variantSelection.swapPartnerVariantKey]);
// Bridge line-count metric should mirror variant layout-shift mode:
// only `'focus'` compares focused lines while collapsed; every other
// mode always compares total lines.
const variantBridgeLineMode = variantLayoutShift === 'focus' ? 'focus' : 'total';
// Sub-hook: File Navigation
const fileNavigation = useFileNavigation({
selectedVariant: renderedVariant,
transformedFiles: transformManagement.transformedFiles,
selectedTransform: transformManagement.selectedTransform,
mainSlug: userProps.slug,
selectedVariantKey: renderedVariantKey,
selectVariant: variantSelection.selectVariantProgrammatic,
variantKeys: variantSelection.variantKeys,
shouldHighlight,
preClassName,
setSource: sourceEditing.setSource,
editActivation: context?.editActivation,
onActivate: context?.onEditingActivated,
effectiveCode,
fileHashMode,
saveHashVariantToLocalStorage,
saveVariantToLocalStorage: variantSelection.saveVariantToLocalStorage,
hashVariant: variantSelection.hashVariant,
sourceEnhancers: mergedEnhancers,
fallbacks: context?.fallbacks,
expanded: uiState.expanded,
collapseToEmpty,
expand,
transforming,
onPreTransitionReady,
variantBridgeLineMode,
swapPartnerVariant,
selectedFileName: selectedFileNameState,
setSelectedFileName: setSelectedFileNameState
});
// Sub-hook: Copy Functionality
const copyFunctionality = useCopyFunctionality({
selectedFile: fileNavigation.selectedFile,
selectedVariant: renderedVariant,
transformedFiles: transformManagement.transformedFiles,
// Per-file dictionaries for the active variant (decodes `hastCompressed`
// sources back to text); `selectedFileFallback` covers the single-file copy.
fallbacks: context?.fallbacks,
selectedFileFallback: fileNavigation.selectedFileFallback,
title: userProps.name,
copyOpts
});
return {
variants: variantSelection.variantKeys,
selectedVariant: variantSelection.selectedVariantKey,
selectVariant: variantSelection.selectVariant,
files: fileNavigation.files,
selectedFile: fileNavigation.selectedFileComponent,
selectedFileLines: fileNavigation.selectedFileLines,
selectedFileName: fileNavigation.selectedFileName,
selectedFileUrl: fileNavigation.selectedFileUrl,
selectedFileSlug: fileNavigation.selectedFileSlug,
selectFileName: fileNavigation.selectFileName,
allFilesSlugs: fileNavigation.allFilesSlugs,
expanded: uiState.expanded,
expand,
setExpanded,
copy: copyFunctionality.copy,
copyMarkdown: copyFunctionality.copyMarkdown,
availableTransforms: transformManagement.availableTransforms,
selectedTransform: transformManagement.selectedTransform,
selectTransform: transformManagement.selectTransform,
pendingTransform: transformManagement.pendingTransform,
setSource: sourceEditing.setSource,
reset: sourceEditing.reset,
refresh: context?.refresh,
userProps
};
}