diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.module.css b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.module.css
new file mode 100644
index 000000000..8eae9c836
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.module.css
@@ -0,0 +1,14 @@
+.code {
+ margin: 1rem 0;
+ background: #fdfcfe;
+ border: 1px solid #d0cdd7;
+ border-radius: 8px;
+ overflow-x: auto;
+}
+
+.codeBlock {
+ margin: 0;
+ display: block;
+ overflow-x: auto;
+ padding: 1em;
+}
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx
new file mode 100644
index 000000000..dd99934a1
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import * as React from 'react';
+
+import type { ContentProps } from '@mui/internal-docs-infra/CodeHighlighter/types';
+import { useCode } from '@mui/internal-docs-infra/useCode';
+
+import styles from './CodeContent.module.css';
+
+import '@wooorm/starry-night/style/light';
+
+export function CodeContent(props: ContentProps<{}>) {
+ const code = useCode(props, { preClassName: styles.codeBlock });
+
+ return
{code.selectedFile}
;
+}
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx
new file mode 100644
index 000000000..8d15dfde8
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx
@@ -0,0 +1,17 @@
+import 'server-only';
+
+import * as React from 'react';
+import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlighter/types';
+import styles from './CodeContent.module.css';
+
+import '@wooorm/starry-night/style/light';
+
+export function CodeContentLoading(props: ContentLoadingProps<{}>) {
+ return (
+
+ );
+}
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/code/index.ts b/docs/app/bench/docs-infra/components/code-highlighter/demos/code/index.ts
new file mode 100644
index 000000000..f99c5a620
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/code/index.ts
@@ -0,0 +1,4 @@
+import { createDemoPerformance } from '@/functions/createDemoPerformance';
+import Page from './page';
+
+export const DemoCodeHighlighterPerformance = createDemoPerformance(import.meta.url, Page);
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/code/page.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/code/page.tsx
new file mode 100644
index 000000000..f61f0f8fc
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/code/page.tsx
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import { createParseSource } from '@mui/internal-docs-infra/pipeline/parseSource';
+import { CodeHighlighter } from '@mui/internal-docs-infra/CodeHighlighter';
+
+import { CodeContent } from '../CodeContent';
+import { CodeContentLoading } from '../CodeContentLoading';
+
+import code from '../../snippets/large/snippet';
+
+const sourceParser = createParseSource();
+
+export default function Page() {
+ return (
+
+ {code}
+
+ );
+}
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/page.mdx b/docs/app/bench/docs-infra/components/code-highlighter/page.mdx
new file mode 100644
index 000000000..ef9aced6c
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/page.mdx
@@ -0,0 +1,9 @@
+# Benchmarking Code Highlighter
+
+This demo showcases the performance of the [`CodeHighlighter`](../../../../docs-infra/components/code-highlighter/page.mdx) component when handling large code snippets. It uses RSC to highlight the code.
+
+import { DemoCodeHighlighterPerformance } from './demos/code';
+
+
+
+[See Setup](./demos/code/)
diff --git a/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts b/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts
new file mode 100644
index 000000000..96baa1add
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts
@@ -0,0 +1,1481 @@
+const snippet: string = `// @ts-check
+
+const { declare } = require('@babel/helper-plugin-utils');
+
+/**
+ * @typedef {import('@babel/core')} babel
+ * @typedef {{ id: babel.types.Expression, computed?: boolean }} ComponentIdentifier
+ */
+
+// remember to set \`cacheDirectory\` to \`false\` when modifying this plugin
+
+const DEFAULT_ALLOWED_CALLEES = {
+ react: ['createContext', 'forwardRef', 'memo'],
+};
+
+/** @type {Map} */
+const calleeModuleMapping = new Map(); // Mapping of callee name to module name
+const seenDisplayNames = new Set();
+
+/**
+ * Applies allowed callees mapping to the internal calleeModuleMapping.
+ * @param {Record} mapping - The mapping of module names to method names.
+ */
+function applyAllowedCallees(mapping) {
+ Object.entries(mapping).forEach(([moduleName, methodNames]) => {
+ methodNames.forEach((methodName) => {
+ const moduleNames = calleeModuleMapping.get(methodName) ?? [];
+ moduleNames.push(moduleName);
+ calleeModuleMapping.set(methodName, moduleNames);
+ });
+ });
+}
+
+module.exports = declare((api, options) => {
+ api.assertVersion(7);
+
+ calleeModuleMapping.clear();
+
+ applyAllowedCallees(DEFAULT_ALLOWED_CALLEES);
+
+ if (options.allowedCallees) {
+ applyAllowedCallees(options.allowedCallees);
+ }
+
+ const t = api.types;
+
+ return {
+ name: '@probablyup/babel-plugin-react-displayname',
+ visitor: {
+ Program() {
+ // We allow duplicate names across files,
+ // so we clear when we're transforming on a new file
+ seenDisplayNames.clear();
+ },
+ 'FunctionExpression|ArrowFunctionExpression|ObjectMethod': (
+ /** @type {babel.NodePath} */ path,
+ ) => {
+ // if the parent is a call expression, make sure it's an allowed one
+ if (
+ path.parentPath && path.parentPath.isCallExpression()
+ ? isAllowedCallExpression(t, path.parentPath)
+ : true
+ ) {
+ if (doesReturnJSX(t, path.node.body)) {
+ addDisplayNamesToFunctionComponent(t, path);
+ }
+ }
+ },
+ CallExpression(path) {
+ if (isAllowedCallExpression(t, path)) {
+ addDisplayNamesToFunctionComponent(t, path);
+ }
+ },
+ },
+ };
+});
+
+/**
+ * Checks if this function returns JSX nodes.
+ * It does not do type-checking, which means calling
+ * other functions that return JSX will still return \`false\`.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.types.Statement | babel.types.Expression} node function node
+ */
+function doesReturnJSX(t, node) {
+ if (!node) {
+ return false;
+ }
+
+ const body = t.toBlock(node).body;
+ if (!body) {
+ return false;
+ }
+
+ return body.some((statement) => {
+ /** @type {babel.Node | null | undefined} */
+ let currentNode;
+
+ if (t.isReturnStatement(statement)) {
+ currentNode = statement.argument;
+ } else if (
+ t.isExpressionStatement(statement) &&
+ !t.isCallExpression(statement.expression)
+ ) {
+ currentNode = statement.expression;
+ } else {
+ return false;
+ }
+
+ if (
+ t.isCallExpression(currentNode) &&
+ // detect *.createElement and count it as returning JSX
+ // this could be improved a lot but will work for the 99% case
+ t.isMemberExpression(currentNode.callee) &&
+ t.isIdentifier(currentNode.callee.property) &&
+ currentNode.callee.property.name === 'createElement'
+ ) {
+ return true;
+ }
+
+ if (t.isConditionalExpression(currentNode)) {
+ return (
+ isJSX(t, currentNode.consequent) || isJSX(t, currentNode.alternate)
+ );
+ }
+
+ if (t.isLogicalExpression(currentNode)) {
+ return isJSX(t, currentNode.left) || isJSX(t, currentNode.right);
+ }
+
+ if (t.isArrayExpression(currentNode)) {
+ return currentNode.elements.some((ele) => isJSX(t, ele));
+ }
+
+ return isJSX(t, currentNode);
+ });
+}
+
+/**
+ * Checks if this node is JSXElement or JSXFragment,
+ * which are the root nodes of react components.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.Node | null | undefined} node babel node
+ */
+function isJSX(t, node) {
+ return t.isJSXElement(node) || t.isJSXFragment(node);
+}
+
+/**
+ * Checks if this path is an allowed CallExpression.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.NodePath} path path of callee
+ */
+function isAllowedCallExpression(t, path) {
+ const calleePath = path.get('callee');
+ const callee = /** @type {babel.types.Expression} */ path.node.callee;
+ /** @type {string | undefined} */
+ const calleeName =
+ /** @type {any} */ callee.name || /** @type {any} */ callee.property?.name;
+ const moduleNames = calleeName && calleeModuleMapping.get(calleeName);
+
+ if (!moduleNames) {
+ return false;
+ }
+
+ // If the callee is an identifier expression, then check if it matches
+ // a named import, e.g. \`import {createContext} from 'react'\`.
+ if (calleePath.isIdentifier()) {
+ return moduleNames.some((moduleName) =>
+ calleePath.referencesImport(moduleName, calleeName),
+ );
+ }
+
+ if (calleePath.isMemberExpression()) {
+ const object = calleePath.get('object');
+
+ return moduleNames.some(
+ (moduleName) =>
+ object.referencesImport(moduleName, 'default') ||
+ object.referencesImport(moduleName, '*'),
+ );
+ }
+
+ return false;
+}
+
+/**
+ * Adds displayName to the function component if it is:
+ * - assigned to a variable or object path
+ * - not within other JSX elements
+ * - not called by a react hook or _createClass helper
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.NodePath} path path of function
+ */
+function addDisplayNamesToFunctionComponent(t, path) {
+ /** @type {ComponentIdentifier[]} */
+ const componentIdentifiers = [];
+ if (/** @type {any} */ path.node.key) {
+ componentIdentifiers.push({ id: /** @type {any} */ path.node.key });
+ }
+
+ /** @type {babel.NodePath | undefined} */
+ let assignmentPath;
+ let hasCallee = false;
+ let hasObjectProperty = false;
+
+ const scopePath = path.scope.parent && path.scope.parent.path;
+ path.find((parentPath) => {
+ // we've hit the scope, stop going further up
+ if (parentPath === scopePath) {
+ return true;
+ }
+
+ // Ignore functions within jsx
+ if (isJSX(t, parentPath.node)) {
+ return true;
+ }
+
+ if (parentPath.isCallExpression()) {
+ // Ignore immediately invoked function expressions (IIFEs)
+ const callee =
+ /** @types {babel.types.Expression} */ parentPath.node.callee;
+ if (
+ t.isArrowFunctionExpression(callee) ||
+ t.isFunctionExpression(callee)
+ ) {
+ return true;
+ }
+
+ // Ignore instances where displayNames are disallowed
+ // _createClass(() => )
+ // useMemo(() => )
+ const calleeName = t.isIdentifier(callee) ? callee.name : undefined;
+ if (
+ calleeName &&
+ (calleeName.startsWith('_') || calleeName.startsWith('use'))
+ ) {
+ return true;
+ }
+
+ hasCallee = true;
+ }
+
+ // componentIdentifier =
+ if (parentPath.isAssignmentExpression()) {
+ assignmentPath = parentPath.parentPath;
+ componentIdentifiers.unshift({
+ id: /** @type {babel.types.Expression} */ parentPath.node.left,
+ });
+ return true;
+ }
+
+ // const componentIdentifier =
+ if (parentPath.isVariableDeclarator()) {
+ // Ternary expression
+ if (t.isConditionalExpression(parentPath.node.init)) {
+ const { consequent, alternate } = parentPath.node.init;
+ const isConsequentFunction =
+ t.isArrowFunctionExpression(consequent) ||
+ t.isFunctionExpression(consequent);
+ const isAlternateFunction =
+ t.isArrowFunctionExpression(alternate) ||
+ t.isFunctionExpression(alternate);
+
+ // Only add display name if variable is a function
+ if (!isConsequentFunction || !isAlternateFunction) {
+ return false;
+ }
+ }
+ assignmentPath = parentPath.parentPath;
+ componentIdentifiers.unshift({
+ id: /** @type {babel.types.Expression} */ parentPath.node.id,
+ });
+ return true;
+ }
+
+ // if this is not a continuous object key: value pair, stop processing it
+ if (
+ hasObjectProperty &&
+ !(parentPath.isObjectProperty() || parentPath.isObjectExpression())
+ ) {
+ return true;
+ }
+
+ // { componentIdentifier: }
+ if (parentPath.isObjectProperty()) {
+ hasObjectProperty = true;
+ const node = parentPath.node;
+ componentIdentifiers.unshift({
+ id: /** @type {babel.types.Expression} */ node.key,
+ computed: node.computed,
+ });
+ }
+
+ return false;
+ });
+
+ if (!assignmentPath || componentIdentifiers.length === 0) {
+ return;
+ }
+
+ const name = generateDisplayName(t, componentIdentifiers);
+
+ const pattern = \`\${name}.displayName\`;
+
+ // disallow duplicate names if they were assigned in different scopes
+ if (
+ seenDisplayNames.has(name) &&
+ !hasBeenAssignedPrev(t, assignmentPath, pattern, name)
+ ) {
+ return;
+ }
+
+ // skip unnecessary addition of name if it is reassigned later on
+ if (hasBeenAssignedNext(t, assignmentPath, pattern)) {
+ return;
+ }
+
+ // at this point we're ready to start pushing code
+
+ if (hasCallee) {
+ // if we're getting called by some wrapper function,
+ // give this function a name
+ setInternalFunctionName(t, path, name);
+ }
+
+ const displayNameStatement = createDisplayNameStatement(
+ t,
+ componentIdentifiers,
+ name,
+ );
+
+ assignmentPath.insertAfter(displayNameStatement);
+
+ seenDisplayNames.add(name);
+}
+
+/**
+ * Generate a displayName string based on the ids collected.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
+ */
+function generateDisplayName(t, componentIdentifiers) {
+ let displayName = '';
+ componentIdentifiers.forEach((componentIdentifier) => {
+ const node = componentIdentifier.id;
+ if (!node) {
+ return;
+ }
+ const name = generateNodeDisplayName(t, node);
+ displayName += componentIdentifier.computed ? \`[\${name}]\` : \`.\${name}\`;
+ });
+
+ return displayName.slice(1);
+}
+
+/**
+ * Generate a displayName string based on the node.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.Node} node identifier or member expression node
+ * @returns {string}
+ */
+function generateNodeDisplayName(t, node) {
+ if (t.isIdentifier(node)) {
+ return node.name;
+ }
+
+ if (t.isMemberExpression(node)) {
+ const objectDisplayName = generateNodeDisplayName(t, node.object);
+ const propertyDisplayName = generateNodeDisplayName(t, node.property);
+
+ const res = node.computed
+ ? \`\${objectDisplayName}[\${propertyDisplayName}]\`
+ : \`\${objectDisplayName}.\${propertyDisplayName}\`;
+ return res;
+ }
+
+ return '';
+}
+
+/**
+ * Checks if this path has been previously assigned to a particular value.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.NodePath} assignmentPath path where assignement will take place
+ * @param {string} pattern assignment path in string form e.g. \`x.y.z\`
+ * @param {string} value assignment value to compare with
+ * @returns {boolean}
+ */
+function hasBeenAssignedPrev(t, assignmentPath, pattern, value) {
+ return assignmentPath.getAllPrevSiblings().some((sibling) => {
+ const expression = /** @type {babel.NodePath} */ sibling.get('expression');
+ if (!t.isAssignmentExpression(expression.node, { operator: '=' })) {
+ return false;
+ }
+ if (!t.isStringLiteral(expression.node.right, { value })) {
+ return false;
+ }
+ return /** @type {babel.NodePath} */ expression
+ .get('left')
+ .matchesPattern(pattern);
+ });
+}
+
+/**
+ * Checks if this path will be assigned later in the scope.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.NodePath} assignmentPath path where assignement will take place
+ * @param {string} pattern assignment path in string form e.g. \`x.y.z\`
+ * @returns {boolean}
+ */
+function hasBeenAssignedNext(t, assignmentPath, pattern) {
+ return assignmentPath.getAllNextSiblings().some((sibling) => {
+ const expression = /** @type {babel.NodePath} */ sibling.get('expression');
+ if (!t.isAssignmentExpression(expression.node, { operator: '=' })) {
+ return false;
+ }
+ return /** @type {babel.NodePath} */ expression
+ .get('left')
+ .matchesPattern(pattern);
+ });
+}
+
+/**
+ * Generate a displayName ExpressionStatement node based on the ids.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
+ * @param {string} displayName name of the function component
+ */
+function createDisplayNameStatement(t, componentIdentifiers, displayName) {
+ const node = createMemberExpression(t, componentIdentifiers);
+
+ const expression = t.assignmentExpression(
+ '=',
+ t.memberExpression(node, t.identifier('displayName')),
+ t.stringLiteral(displayName),
+ );
+
+ const ifStatement = t.ifStatement(
+ t.binaryExpression(
+ '!==',
+ t.memberExpression(
+ t.memberExpression(t.identifier('process'), t.identifier('env')),
+ t.identifier('NODE_ENV'),
+ ),
+ t.stringLiteral('production'),
+ ),
+ t.expressionStatement(expression),
+ );
+
+ return ifStatement;
+}
+
+/**
+ * Helper that creates a MemberExpression node from the ids.
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
+ * @returns {babel.types.Expression}
+ */
+function createMemberExpression(t, componentIdentifiers) {
+ let node = componentIdentifiers[0].id;
+ if (componentIdentifiers.length > 1) {
+ for (let i = 1; i < componentIdentifiers.length; i += 1) {
+ const { id, computed } = componentIdentifiers[i];
+ node = t.memberExpression(node, id, computed);
+ }
+ }
+ return node;
+}
+
+/**
+ * Changes the arrow function to a function expression and gives it a name.
+ * \`name\` will be changed to ensure that it is unique within the scope. e.g. \`helper\` -> \`_helper\`
+ *
+ * @param {babel.types} t content of @babel/types package
+ * @param {babel.NodePath} path path to the function node
+ * @param {string} name name of function to follow after
+ */
+function setInternalFunctionName(t, path, name) {
+ if (
+ !name ||
+ ('id' in path.node && path.node.id != null) ||
+ ('key' in path.node && path.node.key != null)
+ ) {
+ return;
+ }
+
+ const id = path.scope.generateUidIdentifier(name);
+ if (path.isArrowFunctionExpression()) {
+ path.arrowFunctionToExpression();
+ }
+ // @ts-expect-error
+ path.node.id = id;
+}
+
+const cssComponents = ['Box', 'Grid', 'Typography', 'Stack'];
+
+/**
+ * Produces markdown of the description that can be hosted anywhere.
+ *
+ * By default we assume that the markdown is hosted on mui.com which is
+ * why the source includes relative url. We transform them to absolute urls with
+ * this method.
+ */
+export async function computeApiDescription(
+ api: { description: ComponentReactApi['description'] },
+ options: { host: string },
+): Promise {
+ const { host } = options;
+ const file = await remark()
+ .use(function docsLinksAttacher() {
+ return function transformer(tree) {
+ remarkVisit(tree, 'link', (linkNode) => {
+ const link = linkNode as Link;
+ if ((link.url as string).startsWith('/')) {
+ link.url = \`\${host}\${link.url}\`;
+ }
+ });
+ };
+ })
+ .process(api.description);
+
+ return file.toString().trim();
+}
+
+/**
+ * Add demos & API comment block to type definitions, e.g.:
+ * /**
+ * * Demos:
+ * *
+ * * - [Icons](https://mui.com/components/icons/)
+ * * - [Material Icons](https://mui.com/components/material-icons/)
+ * *
+ * * API:
+ * *
+ * * - [Icon API](https://mui.com/api/icon/)
+ */
+async function annotateComponentDefinition(
+ api: ComponentReactApi,
+ componentJsdoc: Annotation,
+ projectSettings: ProjectSettings,
+) {
+ const HOST = projectSettings.baseApiUrl ?? 'https://mui.com';
+
+ const typesFilename = api.filename.replace(/.js$/, '.d.ts');
+ const fileName = path.parse(api.filename).name;
+ const typesSource = readFileSync(typesFilename, { encoding: 'utf8' });
+ const typesAST = await babel.parseAsync(typesSource, {
+ configFile: false,
+ filename: typesFilename,
+ presets: [require.resolve('@babel/preset-typescript')],
+ });
+ if (typesAST === null) {
+ throw new Error('No AST returned from babel.');
+ }
+
+ let start = 0;
+ let end = null;
+ traverse(typesAST, {
+ ExportDefaultDeclaration(babelPath) {
+ /**
+ * export default function Menu() {}
+ */
+ let node: babel.Node = babelPath.node;
+ if (node.declaration.type === 'Identifier') {
+ // declare const Menu: {};
+ // export default Menu;
+ if (babel.types.isIdentifier(babelPath.node.declaration)) {
+ const bindingId = babelPath.node.declaration.name;
+ const binding = babelPath.scope.bindings[bindingId];
+
+ // The JSDoc MUST be located at the declaration
+ if (babel.types.isFunctionDeclaration(binding.path.node)) {
+ // For function declarations the binding is equal to the declaration
+ // /**
+ // */
+ // function Component() {}
+ node = binding.path.node;
+ } else {
+ // For variable declarations the binding points to the declarator.
+ // /**
+ // */
+ // const Component = () => {}
+ node = binding.path.parentPath!.node;
+ }
+ }
+ }
+
+ const { leadingComments } = node;
+ const leadingCommentBlocks =
+ leadingComments != null
+ ? leadingComments.filter(({ type }) => type === 'CommentBlock')
+ : null;
+ const jsdocBlock =
+ leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
+ if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
+ throw new Error(
+ \`Should only have a single leading jsdoc block but got \${
+ leadingCommentBlocks.length
+ }:
+\${leadingCommentBlocks
+ .map(({ type, value }, index) => \`#\${index} (\${type}): \${value}\`)
+ .join('
+')}\`,
+ );
+ }
+ if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
+ start = jsdocBlock.start;
+ end = jsdocBlock.end;
+ } else if (node.start != null) {
+ start = node.start - 1;
+ end = start;
+ }
+ },
+
+ ExportNamedDeclaration(babelPath) {
+ let node: babel.Node = babelPath.node;
+
+ if (node.declaration == null) {
+ // export { Menu };
+ node.specifiers.forEach((specifier) => {
+ if (
+ specifier.type === 'ExportSpecifier' &&
+ specifier.local.name === fileName
+ ) {
+ const binding = babelPath.scope.bindings[specifier.local.name];
+
+ if (babel.types.isFunctionDeclaration(binding.path.node)) {
+ // For function declarations the binding is equal to the declaration
+ // /**
+ // */
+ // function Component() {}
+ node = binding.path.node;
+ } else {
+ // For variable declarations the binding points to the declarator.
+ // /**
+ // */
+ // const Component = () => {}
+ node = binding.path.parentPath!.node;
+ }
+ }
+ });
+ } else if (babel.types.isFunctionDeclaration(node.declaration)) {
+ // export function Menu() {}
+ if (node.declaration.id?.name === fileName) {
+ node = node.declaration;
+ }
+ } else {
+ return;
+ }
+
+ const { leadingComments } = node;
+ const leadingCommentBlocks =
+ leadingComments != null
+ ? leadingComments.filter(({ type }) => type === 'CommentBlock')
+ : null;
+ const jsdocBlock =
+ leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
+ if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
+ throw new Error(
+ \`Should only have a single leading jsdoc block but got \${
+ leadingCommentBlocks.length
+ }:
+\${leadingCommentBlocks
+ .map(({ type, value }, index) => \`#\${index} (\${type}): \${value}\`)
+ .join('
+')}\`,
+ );
+ }
+ if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
+ start = jsdocBlock.start;
+ end = jsdocBlock.end;
+ } else if (node.start != null) {
+ start = node.start - 1;
+ end = start;
+ }
+ },
+ });
+
+ if (end === null || start === 0) {
+ throw new TypeError(
+ \`\${api.filename}: Don't know where to insert the jsdoc block. Probably no default export or named export matching the file name was found.\`,
+ );
+ }
+
+ let inheritanceAPILink = null;
+ if (api.inheritance) {
+ inheritanceAPILink = \`[\${api.inheritance.name} API](\${
+ api.inheritance.apiPathname.startsWith('http')
+ ? api.inheritance.apiPathname
+ : \`\${HOST}\${api.inheritance.apiPathname}\`
+ })\`;
+ }
+
+ const markdownLines = (
+ await computeApiDescription(api, { host: HOST })
+ ).split('
+');
+ // Ensure a newline between manual and generated description.
+ if (markdownLines[markdownLines.length - 1] !== '') {
+ markdownLines.push('');
+ }
+
+ if (api.customAnnotation) {
+ markdownLines.push(
+ ...api.customAnnotation
+ .split('
+')
+ .map((line) => line.trim())
+ .filter(Boolean),
+ );
+ } else {
+ markdownLines.push(
+ 'Demos:',
+ '',
+ ...api.demos.map((demo) => {
+ return \`- [\${demo.demoPageTitle}](\${
+ demo.demoPathname.startsWith('http')
+ ? demo.demoPathname
+ : \`\${HOST}\${demo.demoPathname}\`
+ })\`;
+ }),
+ '',
+ );
+
+ markdownLines.push(
+ 'API:',
+ '',
+ \`- [\${api.name} API](\${
+ api.apiPathname.startsWith('http')
+ ? api.apiPathname
+ : \`\${HOST}\${api.apiPathname}\`
+ })\`,
+ );
+ if (api.inheritance) {
+ markdownLines.push(\`- inherits \${inheritanceAPILink}\`);
+ }
+ }
+
+ if (componentJsdoc.tags.length > 0) {
+ markdownLines.push('');
+ }
+
+ componentJsdoc.tags.forEach((tag) => {
+ markdownLines.push(
+ \`@\${tag.title}\${tag.name ? \` \${tag.name} -\` : ''} \${tag.description}\`,
+ );
+ });
+
+ const jsdoc = \`/**
+\${markdownLines
+ .map((line) => (line.length > 0 ? \` * \${line}\` : \` *\`))
+ .join('
+')}
+ */\`;
+ const typesSourceNew =
+ typesSource.slice(0, start) + jsdoc + typesSource.slice(end);
+ writeFileSync(typesFilename, typesSourceNew, { encoding: 'utf8' });
+}
+
+/**
+ * Substitute CSS class description conditions with placeholder
+ */
+function extractClassCondition(description: string) {
+ const stylesRegex =
+ /((Styles|State class|Class name) applied to )(.*?)(( if | unless | when |, ){1}(.*))?./;
+
+ const conditions = description.match(stylesRegex);
+
+ if (conditions && conditions[6]) {
+ return {
+ description: renderMarkdown(
+ description.replace(stylesRegex, '$1{{nodeName}}$5{{conditions}}.'),
+ ),
+ nodeName: renderMarkdown(conditions[3]),
+ conditions: renderMarkdown(renderCodeTags(conditions[6])),
+ };
+ }
+
+ if (conditions && conditions[3] && conditions[3] !== 'the root element') {
+ return {
+ description: renderMarkdown(
+ description.replace(stylesRegex, '$1{{nodeName}}$5.'),
+ ),
+ nodeName: renderMarkdown(conditions[3]),
+ };
+ }
+
+ return { description: renderMarkdown(description) };
+}
+
+const generateApiPage = async (
+ apiPagesDirectory: string,
+ importTranslationPagesDirectory: string,
+ reactApi: ComponentReactApi,
+ sortingStrategies?: SortingStrategiesType,
+ onlyJsonFile: boolean = false,
+ layoutConfigPath: string = '',
+) => {
+ const normalizedApiPathname = reactApi.apiPathname.replace(/\\/g, '/');
+ /**
+ * Gather the metadata needed for the component's API page.
+ */
+ const pageContent: ComponentApiContent = {
+ // Sorted by required DESC, name ASC
+ props: _.fromPairs(
+ Object.entries(reactApi.propsTable).sort(
+ ([aName, aData], [bName, bData]) => {
+ if (
+ (aData.required && bData.required) ||
+ (!aData.required && !bData.required)
+ ) {
+ return aName.localeCompare(bName);
+ }
+ if (aData.required) {
+ return -1;
+ }
+ return 1;
+ },
+ ),
+ ),
+ name: reactApi.name,
+ imports: reactApi.imports,
+ ...(reactApi.slots?.length > 0 && { slots: reactApi.slots }),
+ ...(Object.keys(reactApi.cssVariables).length > 0 && {
+ cssVariables: reactApi.cssVariables,
+ }),
+ ...(Object.keys(reactApi.dataAttributes).length > 0 && {
+ dataAttributes: reactApi.dataAttributes,
+ }),
+ classes: reactApi.classes,
+ spread: reactApi.spread,
+ themeDefaultProps: reactApi.themeDefaultProps,
+ muiName: normalizedApiPathname.startsWith('/joy-ui')
+ ? reactApi.muiName.replace('Mui', 'Joy')
+ : reactApi.muiName,
+ forwardsRefTo: reactApi.forwardsRefTo,
+ filename: toGitHubPath(reactApi.filename),
+ inheritance: reactApi.inheritance
+ ? {
+ component: reactApi.inheritance.name,
+ pathname: reactApi.inheritance.apiPathname,
+ }
+ : null,
+ demos: \`\`,
+ cssComponent: cssComponents.includes(reactApi.name),
+ deprecated: reactApi.deprecated,
+ };
+
+ const { classesSort = sortAlphabetical('key'), slotsSort = null } = {
+ ...sortingStrategies,
+ };
+
+ if (classesSort) {
+ pageContent.classes = [...pageContent.classes].sort(classesSort);
+ }
+ if (slotsSort && pageContent.slots) {
+ pageContent.slots = [...pageContent.slots].sort(slotsSort);
+ }
+
+ await writePrettifiedFile(
+ path.resolve(apiPagesDirectory, \`\${kebabCase(reactApi.name)}.json\`),
+ JSON.stringify(pageContent),
+ );
+
+
+ export default function Page(props) {
+ const { descriptions, pageContent } = props;
+ return ;
+ }
+
+ Page.getInitialProps = () => {
+ const req = require.context(
+ '\${importTranslationPagesDirectory}/\${kebabCase(reactApi.name)}',
+ false,
+ /\\.\\/\${kebabCase(reactApi.name)}.*.json$/,
+ );
+ const descriptions = mapApiPageTranslations(req);
+
+ return {
+ descriptions,
+ pageContent: jsonPageContent,
+ };
+ };
+ \`.replace(/
+?
+/g, reactApi.EOL),
+ );
+ }
+};
+
+const attachTranslations = (
+ reactApi: ComponentReactApi,
+ deprecationInfo: string | undefined,
+ settings?: CreateDescribeablePropSettings,
+) => {
+ const translations: ComponentReactApi['translations'] = {
+ componentDescription: reactApi.description,
+ deprecationInfo: deprecationInfo
+ ? renderMarkdown(deprecationInfo)
+ : undefined,
+ propDescriptions: {},
+ classDescriptions: {},
+ };
+ Object.entries(reactApi.props!).forEach(([propName, propDescriptor]) => {
+ let prop: DescribeablePropDescriptor | null;
+ try {
+ prop = createDescribeableProp(propDescriptor, propName, settings);
+ } catch (error) {
+ prop = null;
+ }
+ if (prop) {
+ const {
+ deprecated,
+ seeMore,
+ jsDocText,
+ signatureArgs,
+ signatureReturn,
+ requiresRef,
+ } = generatePropDescription(prop, propName);
+ // description = renderMarkdownInline(\`\${description}\`);
+
+ const typeDescriptions: TypeDescriptions = {};
+ (signatureArgs || [])
+ .concat(signatureReturn || [])
+ .forEach(({ name, description, argType, argTypeDescription }) => {
+ typeDescriptions[name] = {
+ name,
+ description: renderMarkdown(description),
+ argType,
+ argTypeDescription: argTypeDescription
+ ? renderMarkdown(argTypeDescription)
+ : undefined,
+ };
+ });
+
+ translations.propDescriptions[propName] = {
+ description: renderMarkdown(jsDocText),
+ requiresRef: requiresRef || undefined,
+ deprecated: renderMarkdown(deprecated) || undefined,
+ typeDescriptions:
+ Object.keys(typeDescriptions).length > 0
+ ? typeDescriptions
+ : undefined,
+ seeMoreText: seeMore?.description,
+ };
+ }
+ });
+
+ /**
+ * Slot descriptions.
+ */
+ if (reactApi.slots?.length > 0) {
+ translations.slotDescriptions = {};
+ [...reactApi.slots]
+ .sort(sortAlphabetical('name')) // Sort to ensure consistency of object key order
+ .forEach((slot: Slot) => {
+ const { name, description } = slot;
+ translations.slotDescriptions![name] = renderMarkdown(description);
+ });
+ }
+
+ /**
+ * CSS class descriptions and deprecations.
+ */
+ [...reactApi.classes]
+ .sort(sortAlphabetical('key')) // Sort to ensure consistency of object key order
+ .forEach((classDefinition) => {
+ translations.classDescriptions[classDefinition.key] = {
+ ...extractClassCondition(classDefinition.description),
+ deprecationInfo: classDefinition.deprecationInfo,
+ };
+ });
+ reactApi.classes.forEach((classDefinition, index) => {
+ delete reactApi.classes[index].deprecationInfo; // store deprecation info in translations only
+ });
+
+ /**
+ * CSS variables descriptions.
+ */
+ if (Object.keys(reactApi.cssVariables).length > 0) {
+ translations.cssVariablesDescriptions = {};
+ [...Object.keys(reactApi.cssVariables)]
+ .sort() // Sort to ensure consistency of object key order
+ .forEach((cssVariableName: string) => {
+ const cssVariable = reactApi.cssVariables[cssVariableName];
+ const { description } = cssVariable;
+ translations.cssVariablesDescriptions![cssVariableName] =
+ renderMarkdown(description);
+ });
+ }
+
+ /**
+ * Data attributes descriptions.
+ */
+ if (Object.keys(reactApi.dataAttributes).length > 0) {
+ translations.dataAttributesDescriptions = {};
+ [...Object.keys(reactApi.dataAttributes)]
+ .sort() // Sort to ensure consistency of object key order
+ .forEach((dataAttributeName: string) => {
+ const dataAttribute = reactApi.dataAttributes[dataAttributeName];
+ const { description } = dataAttribute;
+ translations.dataAttributesDescriptions![dataAttributeName] =
+ renderMarkdown(description);
+ });
+ }
+
+ reactApi.translations = translations;
+};
+
+const attachPropsTable = (
+ reactApi: ComponentReactApi,
+ settings?: CreateDescribeablePropSettings,
+) => {
+ const propErrors: Array<[propName: string, error: Error]> = [];
+ type Pair = [string, ComponentReactApi['propsTable'][string]];
+ const componentProps: ComponentReactApi['propsTable'] = _.fromPairs(
+ Object.entries(reactApi.props!).map(([propName, propDescriptor]): Pair => {
+ let prop: DescribeablePropDescriptor | null;
+ try {
+ prop = createDescribeableProp(propDescriptor, propName, settings);
+ } catch (error) {
+ propErrors.push([\`[\${reactApi.name}] \\\`\${propName}\\\`\`, error as Error]);
+ prop = null;
+ }
+ if (prop === null) {
+ // have to delete \`componentProps.undefined\` later
+ return [] as any;
+ }
+
+ const defaultValue = propDescriptor.jsdocDefaultValue?.value;
+
+ const {
+ signature: signatureType,
+ signatureArgs,
+ signatureReturn,
+ seeMore,
+ } = generatePropDescription(prop, propName);
+ const propTypeDescription = generatePropTypeDescription(
+ propDescriptor.type,
+ );
+ const chainedPropType = getChained(prop.type);
+
+ const requiredProp =
+ prop.required ||
+ prop.type.raw?.includes('.isRequired') ||
+ (chainedPropType !== false && chainedPropType.required);
+
+ const deprecation = (propDescriptor.description || '').match(
+ /@deprecated(s+(?.*))?/,
+ );
+
+ const additionalPropsInfo: AdditionalPropsInfo = {};
+
+ const normalizedApiPathname = reactApi.apiPathname.replace(/\\/g, '/');
+
+ if (propName === 'classes') {
+ additionalPropsInfo.cssApi = true;
+ } else if (propName === 'sx') {
+ additionalPropsInfo.sx = true;
+ } else if (
+ propName === 'slots' &&
+ !normalizedApiPathname.startsWith('/material-ui')
+ ) {
+ additionalPropsInfo.slotsApi = true;
+ } else if (normalizedApiPathname.startsWith('/joy-ui')) {
+ switch (propName) {
+ case 'size':
+ additionalPropsInfo['joy-size'] = true;
+ break;
+ case 'color':
+ additionalPropsInfo['joy-color'] = true;
+ break;
+ case 'variant':
+ additionalPropsInfo['joy-variant'] = true;
+ break;
+ default:
+ }
+ }
+
+ let signature: ComponentReactApi['propsTable'][string]['signature'];
+ if (signatureType !== undefined) {
+ signature = {
+ type: signatureType,
+ describedArgs: signatureArgs?.map((arg) => arg.name),
+ returned: signatureReturn?.name,
+ };
+ }
+ return [
+ propName,
+ {
+ type: {
+ name: propDescriptor.type.name,
+ description:
+ propTypeDescription !== propDescriptor.type.name
+ ? propTypeDescription
+ : undefined,
+ },
+ default: defaultValue,
+ // undefined values are not serialized => saving some bytes
+ required: requiredProp || undefined,
+ deprecated: !!deprecation || undefined,
+ deprecationInfo:
+ renderMarkdown(deprecation?.groups?.info || '').trim() || undefined,
+ signature,
+ additionalInfo:
+ Object.keys(additionalPropsInfo).length === 0
+ ? undefined
+ : additionalPropsInfo,
+ seeMoreLink: seeMore?.link,
+ },
+ ];
+ }),
+ );
+ if (propErrors.length > 0) {
+ throw new Error(
+ \`There were errors creating prop descriptions:
+\${propErrors
+ .map(([propName, error]) => {
+ return \` - \${propName}: \${error}\`;
+ })
+ .join('
+')}\`,
+ );
+ }
+
+ // created by returning the \`[]\` entry
+ delete componentProps.undefined;
+
+ reactApi.propsTable = componentProps;
+};
+
+/**
+ * Helper to get the import options
+ * @param name The name of the component
+ * @param filename The filename where its defined (to infer the package)
+ * @returns an array of import command
+ */
+const defaultGetComponentImports = (name: string, filename: string) => {
+ const githubPath = toGitHubPath(filename);
+ const rootImportPath = githubPath.replace(
+ //packages/mui(?:-(.+?))?/src/.*/,
+ (match, pkg) => \`@mui/\${pkg}\`,
+ );
+
+ const subdirectoryImportPath = githubPath.replace(
+ //packages/mui(?:-(.+?))?/src/([^\\/]+)/.*/,
+ (match, pkg, directory) => \`@mui/\${pkg}/\${directory}\`,
+ );
+
+ let namedImportName = name;
+ const defaultImportName = name;
+
+ if (githubPath.includes('Unstable_')) {
+ namedImportName = \`Unstable_\${name} as \${name}\`;
+ }
+
+ const useNamedImports = rootImportPath === '@mui/base';
+
+
+ return [subpathImport, rootImport];
+};
+
+const attachTable = (
+ reactApi: ComponentReactApi,
+ params: ParsedProperty[],
+ attribute: 'cssVariables' | 'dataAttributes',
+ defaultType?: string,
+) => {
+ const errors: Array<[propName: string, error: Error]> = [];
+ const data: { [key: string]: ApiItemDescription } = params
+ .map((p) => {
+ const { name: propName, ...propDescriptor } = p;
+ let prop: Omit | null;
+ try {
+ prop = propDescriptor;
+ } catch (error) {
+ errors.push([propName, error as Error]);
+ prop = null;
+ }
+ if (prop === null) {
+ // have to delete \`componentProps.undefined\` later
+ return [] as any;
+ }
+
+ const deprecationTag = propDescriptor.tags?.deprecated;
+ const deprecation = deprecationTag?.text?.[0]?.text;
+
+ const typeTag = propDescriptor.tags?.type;
+
+ let type = typeTag?.text?.[0]?.text ?? defaultType;
+ if (typeof type === 'string') {
+ type = type.replace(/{|}/g, '');
+ }
+
+ return {
+ name: propName,
+ description: propDescriptor.description,
+ type,
+ deprecated: !!deprecation || undefined,
+ deprecationInfo: renderMarkdown(deprecation || '').trim() || undefined,
+ };
+ })
+ .reduce((acc, cssVarDefinition) => {
+ const { name, ...rest } = cssVarDefinition;
+ return {
+ ...acc,
+ [name]: rest,
+ };
+ }, {});
+
+ if (errors.length > 0) {
+ throw new Error(
+ \`There were errors creating \${attribute.replace(/([A-Z])/g, ' $1')} descriptions:
+\${errors
+ .map(([item, error]) => {
+ return \` - \${item}: \${error}\`;
+ })
+ .join('
+')}\`,
+ );
+ }
+
+ reactApi[attribute] = data;
+};
+
+/**
+ * - Build react component (specified filename) api by lookup at its definition (.d.ts or ts)
+ * and then generate the API page + json data
+ * - Generate the translations
+ * - Add the comment in the component filename with its demo & API urls (including the inherited component).
+ * this process is done by sourcing markdown files and filter matched \`components\` in the frontmatter
+ */
+export default async function generateComponentApi(
+ componentInfo: ComponentInfo,
+ project: TypeScriptProject,
+ projectSettings: ProjectSettings,
+) {
+ const { shouldSkip, spread, EOL, src } = componentInfo.readFile();
+
+ if (shouldSkip) {
+ return null;
+ }
+
+ const filename = componentInfo.filename;
+ let reactApi: ComponentReactApi;
+
+ try {
+ reactApi = docgenParse(
+ src,
+ null,
+ defaultHandlers.concat(muiDefaultPropsHandler),
+ {
+ filename,
+ },
+ );
+ } catch (error) {
+ // fallback to default logic if there is no \`create*\` definition.
+ if (
+ (error as Error).message === 'No suitable component definition found.'
+ ) {
+ reactApi = docgenParse(
+ src,
+ (ast) => {
+ let node;
+ // TODO migrate to react-docgen v6, using Babel AST now
+ astTypes.visit(ast, {
+ visitFunctionDeclaration: (functionPath) => {
+ // @ts-ignore
+ if (functionPath.node.params[0].name === 'props') {
+ node = functionPath;
+ }
+ return false;
+ },
+ visitVariableDeclaration: (variablePath) => {
+ const definitions: any[] = [];
+ if (variablePath.node.declarations) {
+ variablePath
+ .get('declarations')
+ .each((declarator: any) =>
+ definitions.push(declarator.get('init')),
+ );
+ }
+ definitions.forEach((definition) => {
+ // definition.value.expression is defined when the source is in TypeScript.
+ const expression = definition.value?.expression
+ ? definition.get('expression')
+ : definition;
+ if (expression.value?.callee) {
+ const definitionName = expression.value.callee.name;
+ if (definitionName === \`create\${componentInfo.name}\`) {
+ node = expression;
+ }
+ }
+ });
+ return false;
+ },
+ });
+
+ return node;
+ },
+ defaultHandlers.concat(muiDefaultPropsHandler),
+ {
+ filename,
+ },
+ );
+ } else {
+ throw error;
+ }
+ }
+
+ if (!reactApi.props) {
+ reactApi.props = {};
+ }
+
+ const { getComponentImports = defaultGetComponentImports } = projectSettings;
+ const componentJsdoc = parseDoctrine(reactApi.description);
+
+ // We override \`reactApi.description\` with \`componentJsdoc.description\` because
+ // the former can include JSDoc tags that we don't want to render in the docs.
+ reactApi.description = componentJsdoc.description;
+
+ // Ignore what we might have generated in \`annotateComponentDefinition\`
+ let annotationBoundary: RegExp = /(Demos|API):
+?
+
+?
+/;
+ if (componentInfo.customAnnotation) {
+ annotationBoundary = new RegExp(
+ escapeRegExp(componentInfo.customAnnotation.trim().split('
+')[0].trim()),
+ );
+ }
+ const annotatedDescriptionMatch = reactApi.description.match(
+ new RegExp(annotationBoundary),
+ );
+ if (annotatedDescriptionMatch !== null) {
+ reactApi.description = reactApi.description
+ .slice(0, annotatedDescriptionMatch.index)
+ .trim();
+ }
+
+ reactApi.filename = filename;
+ reactApi.name = componentInfo.name;
+ reactApi.imports = getComponentImports(componentInfo.name, filename);
+ reactApi.muiName = componentInfo.muiName;
+ reactApi.apiPathname = componentInfo.apiPathname;
+ reactApi.EOL = EOL;
+ reactApi.slots = [];
+ reactApi.classes = [];
+ reactApi.demos = componentInfo.getDemos();
+ reactApi.customAnnotation = componentInfo.customAnnotation;
+ reactApi.inheritance = null;
+ if (reactApi.demos.length === 0) {
+ throw new Error(
+ 'Unable to find demos.
+' +
+ \`Be sure to include \\\`components: \${reactApi.name}\\\` in the markdown pages where the \\\`\${reactApi.name}\\\` component is relevant. \` +
+ 'Every public component should have a demo.
+For internal component, add the name of the component to the \`skipComponent\` method of the product.',
+ );
+ }
+
+ try {
+ const testInfo = await parseTest(reactApi.filename);
+ // no Object.assign to visually check for collisions
+ reactApi.forwardsRefTo = testInfo.forwardsRefTo;
+ reactApi.spread = testInfo.spread ?? spread;
+ reactApi.themeDefaultProps = testInfo.themeDefaultProps;
+ reactApi.inheritance = componentInfo.getInheritance(
+ testInfo.inheritComponent,
+ );
+ } catch (error: any) {
+ console.error(error.message);
+ if (project.name.includes('grid')) {
+ // TODO: Use \`describeConformance\` for the DataGrid components
+ reactApi.forwardsRefTo = 'GridRoot';
+ }
+ }
+
+ if (!projectSettings.skipSlotsAndClasses) {
+ const { slots, classes } = parseSlotsAndClasses({
+ typescriptProject: project,
+ projectSettings,
+ componentName: reactApi.name,
+ muiName: reactApi.muiName,
+ slotInterfaceName: componentInfo.slotInterfaceName,
+ });
+
+ reactApi.slots = slots;
+ reactApi.classes = classes;
+ }
+
+ const deprecation = componentJsdoc.tags.find(
+ (tag) => tag.title === 'deprecated',
+ );
+ const deprecationInfo = deprecation?.description || undefined;
+
+ reactApi.deprecated = !!deprecation || undefined;
+
+ const cssVars = await extractInfoFromEnum(
+ \`\${componentInfo.name}CssVars\`,
+ new RegExp(\`\${componentInfo.name}(CssVars|Classes)?.tsx?$\`, 'i'),
+ project,
+ );
+
+ const dataAttributes = await extractInfoFromEnum(
+ \`\${componentInfo.name}DataAttributes\`,
+ new RegExp(\`\${componentInfo.name}(DataAttributes)?.tsx?$\`, 'i'),
+ project,
+ );
+
+ attachPropsTable(reactApi, projectSettings.propsSettings);
+ attachTable(reactApi, cssVars, 'cssVariables', 'string');
+ attachTable(reactApi, dataAttributes, 'dataAttributes');
+ attachTranslations(reactApi, deprecationInfo, projectSettings.propsSettings);
+
+ // eslint-disable-next-line no-console
+ console.log('Built API docs for', reactApi.apiPathname);
+
+ if (!componentInfo.skipApiGeneration) {
+ const {
+ skipAnnotatingComponentDefinition,
+ translationPagesDirectory,
+ importTranslationPagesDirectory,
+ generateJsonFileOnly,
+ } = projectSettings;
+
+ await generateApiTranslations(
+ path.join(process.cwd(), translationPagesDirectory),
+ reactApi,
+ projectSettings.translationLanguages,
+ );
+
+ // Once we have the tabs API in all projects, we can make this default
+ await generateApiPage(
+ componentInfo.apiPagesDirectory,
+ importTranslationPagesDirectory ?? translationPagesDirectory,
+ reactApi,
+ projectSettings.sortingStrategies,
+ generateJsonFileOnly,
+ componentInfo.layoutConfigPath,
+ );
+
+ if (
+ typeof skipAnnotatingComponentDefinition === 'function'
+ ? !skipAnnotatingComponentDefinition(reactApi.filename)
+ : !skipAnnotatingComponentDefinition
+ ) {
+ // Add comment about demo & api links (including inherited component) to the component file
+ await annotateComponentDefinition(
+ reactApi,
+ componentJsdoc,
+ projectSettings,
+ );
+ }
+ }
+
+ return reactApi;
+}
+`;
+
+// TODO: it would be better to get a large example that passes our linting
+// that way we can save the contents as a `.ts` file and let the builder load it
+export default snippet;
diff --git a/docs/app/bench/layout.tsx b/docs/app/bench/layout.tsx
new file mode 100644
index 000000000..c009897eb
--- /dev/null
+++ b/docs/app/bench/layout.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import type { Metadata } from 'next';
+
+import { BenchProvider } from '@/components/BenchProvider';
+import styles from '../layout.module.css';
+
+export const metadata: Metadata = {
+ title: 'MUI Infra Benchmarks',
+ description: 'Performance demos for MUI Infra packages',
+ robots: { index: false, follow: false },
+};
+
+export default function Layout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+ );
+}
diff --git a/docs/app/docs-infra/components/code-highlighter/bench/page.mdx b/docs/app/docs-infra/components/code-highlighter/bench/page.mdx
new file mode 100644
index 000000000..3e97d92d9
--- /dev/null
+++ b/docs/app/docs-infra/components/code-highlighter/bench/page.mdx
@@ -0,0 +1,5 @@
+import BenchCodeHighlighterPage from '@/app/bench/docs-infra/components/code-highlighter/page.mdx';
+
+export default BenchCodeHighlighterPage;
+
+[See Benchmarks](/docs/app/bench/docs-infra/components/code-highlighter/page.mdx)
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/BasicCode.tsx b/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/BasicCode.tsx
similarity index 100%
rename from docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/BasicCode.tsx
rename to docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/BasicCode.tsx
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/Code.tsx b/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/Code.tsx
similarity index 100%
rename from docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/Code.tsx
rename to docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/Code.tsx
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/index.ts b/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/index.ts
similarity index 65%
rename from docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/index.ts
rename to docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/index.ts
index 89ace6b81..186f42048 100644
--- a/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-idle/index.ts
+++ b/docs/app/docs-infra/components/code-highlighter/demos/code-highlight-init/index.ts
@@ -1,4 +1,4 @@
import { createDemo } from '@/functions/createDemo';
import { BasicCode } from './BasicCode';
-export const DemoCodeHighlighterCodeHighlightIdle = createDemo(import.meta.url, BasicCode);
+export const DemoCodeHighlighterCodeHighlightInit = createDemo(import.meta.url, BasicCode);
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/createDemo.ts b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/createDemo.ts
index d80be6d2b..f4cd9a06a 100644
--- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/createDemo.ts
+++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/createDemo.ts
@@ -7,6 +7,7 @@ import {
import { DemoContentLoading } from './DemoContentLoading';
import { DemoContent } from '../DemoContent';
+import { DemoTitle } from '../DemoTitle';
/**
* Creates a demo component for displaying code examples with syntax highlighting.
@@ -17,6 +18,7 @@ import { DemoContent } from '../DemoContent';
export const createDemo = createDemoFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
fallbackUsesExtraFiles: true,
});
@@ -30,5 +32,6 @@ export const createDemo = createDemoFactory({
export const createDemoWithVariants = createDemoWithVariantsFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
fallbackUsesExtraFiles: true,
});
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/createDemo.ts b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/createDemo.ts
index c1c5cfda4..f07074ffe 100644
--- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/createDemo.ts
+++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/createDemo.ts
@@ -7,6 +7,7 @@ import {
import { DemoContentLoading } from './DemoContentLoading';
import { DemoContent } from '../DemoContent';
+import { DemoTitle } from '../DemoTitle';
/**
* Creates a demo component for displaying code examples with syntax highlighting.
@@ -17,6 +18,7 @@ import { DemoContent } from '../DemoContent';
export const createDemo = createDemoFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
fallbackUsesAllVariants: true,
});
@@ -30,5 +32,6 @@ export const createDemo = createDemoFactory({
export const createDemoWithVariants = createDemoWithVariantsFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
fallbackUsesAllVariants: true,
});
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/createDemo.ts b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/createDemo.ts
index 63dc52301..c9b95fcae 100644
--- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/createDemo.ts
+++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/createDemo.ts
@@ -7,6 +7,7 @@ import {
import { DemoContentLoading } from './DemoContentLoading';
import { DemoContent } from '../DemoContent';
+import { DemoTitle } from '../DemoTitle';
/**
* Creates a demo component for displaying code examples with syntax highlighting.
@@ -17,6 +18,7 @@ import { DemoContent } from '../DemoContent';
export const createDemo = createDemoFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
});
/**
@@ -29,4 +31,5 @@ export const createDemo = createDemoFactory({
export const createDemoWithVariants = createDemoWithVariantsFactory({
DemoContentLoading,
DemoContent,
+ DemoTitle,
});
diff --git a/docs/app/docs-infra/components/code-highlighter/page.mdx b/docs/app/docs-infra/components/code-highlighter/page.mdx
index 8dac5e77a..cc7c820d8 100644
--- a/docs/app/docs-infra/components/code-highlighter/page.mdx
+++ b/docs/app/docs-infra/components/code-highlighter/page.mdx
@@ -157,15 +157,16 @@ One of CodeHighlighter's most powerful features is automatic code transformation
---
-import { DemoCodeHighlighterCodeHighlightIdle } from './demos/code-highlight-idle';
+import { DemoCodeHighlighterCodeHighlightInit } from './demos/code-highlight-init';
-
+
-For pages with many code examples, you can defer syntax highlighting until the browser is idle, keeping your initial page loads fast:
+Generally, since hydration occurs quickly, a slight delay is acceptable to prioritize faster content rendering.
+For demos shown above the fold, you might want highlighting to show immediately on initial render.
-
+
-[See Demo](./demos/code-highlight-idle/)
+[See Demo](./demos/code-highlight-init/)
---
@@ -232,6 +233,12 @@ You can do so by using the [`CodeProvider`](../code-provider/page.mdx) component
You can use the [`CodeControllerContext`](../code-controller-context/page.mdx) for this purpose, which provides a controlled environment for managing code state in interactive scenarios.
+## Benchmarking
+
+We have created some demos that showcase the performance of various configurations.
+
+[See Performance Considerations](./bench/page.mdx)
+
## Authoring Recommendations
### Component Name
diff --git a/docs/components/BenchProvider/BenchProvider.tsx b/docs/components/BenchProvider/BenchProvider.tsx
new file mode 100644
index 000000000..9fecb4dd9
--- /dev/null
+++ b/docs/components/BenchProvider/BenchProvider.tsx
@@ -0,0 +1,457 @@
+'use client';
+
+import * as React from 'react';
+import { onCLS, onFCP, onLCP, onINP } from 'web-vitals/attribution';
+import type { Metric } from 'web-vitals/attribution';
+
+// Helper function for severity assessment
+const getLongTaskSeverity = (duration: number): 'blocking' | 'concerning' | 'minor' => {
+ if (duration > 100) {
+ return 'blocking';
+ }
+ if (duration > 50) {
+ return 'concerning';
+ }
+ return 'minor';
+};
+const getRating = (
+ value: number,
+ thresholds: { good: number; poor: number },
+): 'good' | 'needs-improvement' | 'poor' => {
+ if (value <= thresholds.good) {
+ return 'good';
+ }
+ if (value <= thresholds.poor) {
+ return 'needs-improvement';
+ }
+ return 'poor';
+};
+
+// Custom metric type for TTI and long tasks
+interface CustomMetric {
+ name: string;
+ value: number;
+ rating?: 'good' | 'needs-improvement' | 'poor';
+ navigationType?: string;
+ entries?: any[];
+ metadata?: {
+ startTime?: number;
+ endTime?: number;
+ attribution?: any[];
+ source?: string;
+ timeFromPageLoad?: number;
+ severity?: 'blocking' | 'concerning' | 'minor';
+ };
+}
+
+const report = (metric: Metric | CustomMetric) => {
+ if (!window.parent) {
+ // eslint-disable-next-line no-console
+ console.log(metric);
+ return;
+ }
+
+ // Serialize entries to avoid cloning issues with PerformanceEntry objects
+ const serializableEntries =
+ 'entries' in metric && metric.entries
+ ? metric.entries.map((entry) => ({
+ name: entry.name,
+ entryType: entry.entryType,
+ startTime: entry.startTime,
+ duration: entry.duration,
+ // Add other relevant properties that are serializable
+ ...(entry.size !== undefined && { size: entry.size }),
+ ...(entry.renderTime !== undefined && { renderTime: entry.renderTime }),
+ ...(entry.loadTime !== undefined && { loadTime: entry.loadTime }),
+ }))
+ : undefined;
+
+ window.parent.postMessage(
+ {
+ source: 'docs-infra:bench',
+ type: 'web-vitals',
+ metric: {
+ name: metric.name,
+ navigationType: metric.navigationType,
+ rating: metric.rating,
+ value: metric.value,
+ ...(serializableEntries && { entries: serializableEntries }),
+ ...('metadata' in metric && metric.metadata && { metadata: metric.metadata }),
+ },
+ },
+ '*',
+ );
+};
+
+// Helper functions for TTI calculation
+const findLastLongTaskBefore = (
+ time: number,
+ longTasks: PerformanceEntry[],
+): PerformanceEntry | null => {
+ let lastTask = null;
+ for (const task of longTasks) {
+ if (task.startTime < time) {
+ lastTask = task;
+ } else {
+ break;
+ }
+ }
+ return lastTask;
+};
+
+const isQuietWindow = (
+ startTime: number,
+ endTime: number,
+ longTasks: PerformanceEntry[],
+ networkRequests: PerformanceEntry[],
+): boolean => {
+ // Early exit: Check for long tasks in this window using binary search approach
+ // Since longTasks are already sorted, we can optimize this check
+ const firstTaskInWindow = longTasks.find((task) => task.startTime >= startTime);
+ if (firstTaskInWindow && firstTaskInWindow.startTime < endTime) {
+ return false;
+ }
+
+ // Fast path: if no network requests, it's quiet
+ if (networkRequests.length === 0) {
+ return true;
+ }
+
+ // Optimized network request check - only process relevant requests
+ let maxConcurrentRequests = 0;
+ let currentRequests = 0;
+
+ // Pre-filter and create events only for relevant requests
+ const relevantEvents: Array<{ time: number; type: 'start' | 'end' }> = [];
+
+ for (let i = 0; i < networkRequests.length; i += 1) {
+ const request = networkRequests[i];
+ const reqStartTime = request.startTime;
+ const reqEndTime = reqStartTime + request.duration;
+
+ // Skip requests that don't overlap with our window
+ if (reqEndTime <= startTime || reqStartTime >= endTime) {
+ continue;
+ }
+
+ relevantEvents.push(
+ { time: Math.max(reqStartTime, startTime), type: 'start' },
+ { time: Math.min(reqEndTime, endTime), type: 'end' },
+ );
+ }
+
+ // Early exit if no relevant events
+ if (relevantEvents.length === 0) {
+ return true;
+ }
+
+ // Sort events by time (this is now a smaller array)
+ relevantEvents.sort((a, b) => a.time - b.time);
+
+ // Process events to find max concurrent requests
+ for (let i = 0; i < relevantEvents.length; i += 1) {
+ const event = relevantEvents[i];
+ if (event.type === 'start') {
+ currentRequests += 1;
+ maxConcurrentRequests = Math.max(maxConcurrentRequests, currentRequests);
+ // Early exit optimization: if we already exceed 2, no need to continue
+ if (maxConcurrentRequests > 2) {
+ return false;
+ }
+ } else {
+ currentRequests -= 1;
+ }
+ }
+
+ return maxConcurrentRequests <= 2;
+};
+
+const findTTI = (
+ fcpTime: number,
+ longTasks: PerformanceEntry[],
+ networkRequests: PerformanceEntry[],
+): number => {
+ const searchStartTime = fcpTime;
+ const now = performance.now();
+
+ // Sort long tasks by start time
+ const sortedLongTasks = longTasks
+ .filter((task) => task.startTime >= searchStartTime)
+ .sort((a, b) => a.startTime - b.startTime);
+
+ // Find a quiet window of 5 seconds
+ for (let time = searchStartTime; time <= now - 5000; time += 100) {
+ if (isQuietWindow(time, time + 5000, sortedLongTasks, networkRequests)) {
+ // Found quiet window, now search backwards for last long task
+ const lastLongTask = findLastLongTaskBefore(time, sortedLongTasks);
+ return lastLongTask ? lastLongTask.startTime + lastLongTask.duration : fcpTime;
+ }
+ }
+
+ // If no quiet window found, use current time
+ const lastLongTask = sortedLongTasks[sortedLongTasks.length - 1];
+ return lastLongTask ? lastLongTask.startTime + lastLongTask.duration : fcpTime;
+};
+
+// Global collection of all long tasks for consistent reporting
+// eslint-disable-next-line prefer-const
+let globalLongTasks: PerformanceEntry[] = [];
+let globalLongTaskObserver: PerformanceObserver | null = null;
+
+// Initialize global long task observer
+const initializeGlobalLongTaskObserver = () => {
+ if (globalLongTaskObserver || !('PerformanceObserver' in window)) {
+ return;
+ }
+
+ globalLongTaskObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ globalLongTasks.push(entry);
+ }
+ });
+
+ try {
+ globalLongTaskObserver.observe({ type: 'longtask', buffered: true });
+ } catch {
+ // Long task API not supported
+ globalLongTaskObserver = null;
+ }
+};
+
+// Calculate TBT (Total Blocking Time) between FCP and TTI
+const calculateTBT = (fcpTime: number, ttiTime: number): number => {
+ let totalBlockingTime = 0;
+
+ for (const task of globalLongTasks) {
+ // Only consider tasks between FCP and TTI
+ if (task.startTime >= fcpTime && task.startTime < ttiTime) {
+ // Only the portion above 50ms counts as blocking time
+ const blockingTime = Math.max(0, task.duration - 50);
+ totalBlockingTime += blockingTime;
+ }
+ }
+
+ return totalBlockingTime;
+};
+
+// Calculate TTI and TBT based on the algorithm described
+const calculateTTIAndTBT = (fcpTime: number): Promise<{ tti: number; tbt: number }> => {
+ return new Promise((resolve) => {
+ const networkRequests: PerformanceEntry[] = [];
+ let intervalId: NodeJS.Timeout;
+ let resourceObserver: PerformanceObserver | null = null;
+
+ // Initialize the global long task observer if not already done
+ initializeGlobalLongTaskObserver();
+
+ // Collect all long tasks
+ // (Using global observer initialized above)
+
+ // Collect network requests
+ if ('PerformanceObserver' in window) {
+ resourceObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (entry.name.startsWith('http') && entry.startTime >= fcpTime) {
+ networkRequests.push(entry);
+ }
+ }
+ });
+
+ try {
+ resourceObserver.observe({ type: 'resource', buffered: true });
+ } catch {
+ // Resource timing not supported
+ resourceObserver = null;
+ }
+ }
+
+ // Function to check for quiet window and calculate TTI
+ const checkForTTI = () => {
+ const now = performance.now();
+
+ // Sort long tasks by start time for efficient processing
+ const sortedLongTasks = globalLongTasks
+ .filter((task) => task.startTime >= fcpTime)
+ .sort((a, b) => a.startTime - b.startTime);
+
+ // Check if we can find a quiet window
+ for (let time = fcpTime; time <= now - 5000; time += 100) {
+ if (isQuietWindow(time, time + 5000, sortedLongTasks, networkRequests)) {
+ // Found quiet window! Calculate TTI and TBT, then resolve
+ const lastLongTask = findLastLongTaskBefore(time, sortedLongTasks);
+ const tti = lastLongTask ? lastLongTask.startTime + lastLongTask.duration : fcpTime;
+ const tbt = calculateTBT(fcpTime, tti);
+
+ // Clean up observers and interval
+ clearInterval(intervalId);
+ if (resourceObserver) {
+ resourceObserver.disconnect();
+ }
+
+ resolve({ tti, tbt });
+ return;
+ }
+ }
+ };
+
+ // Check every second for quiet window
+ intervalId = setInterval(checkForTTI, 1000);
+
+ // Fallback: if we haven't found TTI after 24.9 more seconds, calculate it anyway
+ // (Total of 30 seconds from FCP: 5.1 second delay + 24.9 second search)
+ setTimeout(() => {
+ clearInterval(intervalId);
+ if (resourceObserver) {
+ resourceObserver.disconnect();
+ }
+
+ const tti = findTTI(fcpTime, globalLongTasks, networkRequests);
+ const tbt = calculateTBT(fcpTime, tti);
+ resolve({ tti, tbt });
+ }, 24900); // Fallback after 24.9 more seconds (total 30s from FCP)
+ });
+};
+
+// Report long tasks with callback pattern similar to web-vitals
+const onLongTask = (callback: (metric: CustomMetric) => void) => {
+ if (!('PerformanceObserver' in window)) {
+ return;
+ }
+
+ // Initialize global observer if not already done
+ initializeGlobalLongTaskObserver();
+
+ // Create a separate observer just for reporting individual tasks with metadata
+ const reportingObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ // Try to extract better source information
+ let source = 'JavaScript (main thread)'; // Default fallback
+ const attribution = [];
+
+ // Check if attribution data is available
+ if ((entry as any).attribution && (entry as any).attribution.length > 0) {
+ const attr = (entry as any).attribution[0];
+ attribution.push({
+ name: attr.name || 'unknown',
+ entryType: attr.entryType || 'unknown',
+ startTime: attr.startTime || 0,
+ duration: attr.duration || 0,
+ containerType: attr.containerType || 'unknown',
+ containerSrc: attr.containerSrc || 'unknown',
+ containerId: attr.containerId || 'unknown',
+ containerName: attr.containerName || 'unknown',
+ });
+
+ // Try to determine a meaningful source name
+ if (attr.containerSrc && attr.containerSrc !== 'unknown') {
+ try {
+ const url = new URL(attr.containerSrc);
+ source = url.pathname.split('/').pop() || attr.containerSrc;
+ } catch {
+ source = attr.containerSrc;
+ }
+ } else if (attr.containerName && attr.containerName !== 'unknown') {
+ source = attr.containerName;
+ } else if (attr.name && attr.name !== 'unknown') {
+ source = attr.name;
+ }
+ }
+
+ // If no attribution, try to infer from current script context
+ if (attribution.length === 0) {
+ // Check if we can get current script information
+ const currentScript = document.currentScript as HTMLScriptElement;
+ if (currentScript && currentScript.src) {
+ try {
+ const url = new URL(currentScript.src);
+ source = url.pathname.split('/').pop() || 'current-script';
+ } catch {
+ source = 'current-script';
+ }
+ } else {
+ // Fallback to detecting if it's likely React/framework related
+ const hasReact = (window as any).React !== undefined;
+ // eslint-disable-next-line no-underscore-dangle
+ const hasNextData = (window as any).__NEXT_DATA__ !== undefined;
+
+ if (hasNextData) {
+ source = 'Next.js';
+ } else if (hasReact) {
+ source = 'React';
+ }
+ }
+ }
+
+ // Call the callback with each long task and additional metadata
+ callback({
+ name: 'long-task',
+ value: entry.duration,
+ rating: getRating(entry.duration, { good: 50, poor: 100 }),
+ entries: [entry],
+ // Additional metadata for long tasks
+ metadata: {
+ startTime: entry.startTime,
+ endTime: entry.startTime + entry.duration,
+ attribution,
+ source, // Simplified source name for display
+ // Calculate timing relative to page load
+ timeFromPageLoad: entry.startTime,
+ severity: getLongTaskSeverity(entry.duration),
+ },
+ });
+ }
+ });
+
+ try {
+ reportingObserver.observe({ type: 'longtask', buffered: true });
+ } catch {
+ // Long task API not supported
+ console.warn('Long Task API not supported');
+ }
+};
+
+export function BenchProvider({ children }: { children: React.ReactNode }) {
+ React.useEffect(() => {
+ // Initialize global long task collection
+ initializeGlobalLongTaskObserver();
+
+ onCLS(report); // Measures Cumulative Layout Shift
+ onFCP((metric) => {
+ report(metric); // Report FCP first
+
+ // Wait 5.1 seconds before starting TTI calculation to ensure we have enough
+ // time for a potential 5-second quiet window to be meaningful
+ setTimeout(() => {
+ // Calculate and report TTI and TBT after sufficient time has passed
+ calculateTTIAndTBT(metric.value)
+ .then(({ tti, tbt }) => {
+ // Report TTI
+ report({
+ name: 'TTI',
+ value: tti,
+ rating: getRating(tti, { good: 3800, poor: 5000 }),
+ navigationType: metric.navigationType,
+ });
+
+ // Report TBT
+ report({
+ name: 'TBT',
+ value: tbt,
+ rating: getRating(tbt, { good: 200, poor: 600 }),
+ navigationType: metric.navigationType,
+ });
+ })
+ .catch(() => {
+ // TTI/TBT calculation failed, ignore
+ });
+ }, 5100); // Wait 5.1 seconds after FCP before starting TTI calculation
+ }); // Measures First Contentful Paint
+ onLCP(report); // Measures Largest Contentful Paint
+ onINP(report); // Measures Interaction to Next Paint
+
+ // Start reporting long tasks
+ onLongTask(report);
+ }, []);
+
+ return children;
+}
diff --git a/docs/components/BenchProvider/index.ts b/docs/components/BenchProvider/index.ts
new file mode 100644
index 000000000..aee8dbd42
--- /dev/null
+++ b/docs/components/BenchProvider/index.ts
@@ -0,0 +1 @@
+export * from './BenchProvider';
diff --git a/docs/components/BenchViewer/BenchViewer.module.css b/docs/components/BenchViewer/BenchViewer.module.css
new file mode 100644
index 000000000..7af62a0fa
--- /dev/null
+++ b/docs/components/BenchViewer/BenchViewer.module.css
@@ -0,0 +1,135 @@
+.Root {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.Button {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 2.5rem;
+ padding: 0 0.875rem;
+ margin: 0;
+ outline: 0;
+ border: 1px solid #d0cdd7;
+ border-radius: 0.375rem;
+ background-color: #fdfcfe;
+ font-family: inherit;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5rem;
+ color: #65636d;
+ user-select: none;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: #30004010;
+ }
+ }
+
+ &:active {
+ background-color: #20003820;
+ }
+
+ &:focus-visible {
+ outline: 2px solid #8e4ec6;
+ outline-offset: -1px;
+ }
+}
+
+.Backdrop {
+ position: fixed;
+ inset: 0;
+ background-color: #fefcfe;
+ opacity: 0.2;
+ transition: opacity 150ms cubic-bezier(0.45, 1.005, 0, 1.005);
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ opacity: 0;
+ }
+}
+
+.Popup {
+ box-sizing: border-box;
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 1120px;
+ max-width: calc(100vw - 3rem);
+ height: 85%;
+ margin-top: -2rem;
+ padding: 1.5rem;
+ border-radius: 0.5rem;
+ outline: 1px solid #d0cdd7;
+ background-color: #fefcfe;
+ transition: all 150ms;
+ z-index: 100;
+ display: flex;
+ gap: 1rem;
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+}
+
+.Interactive {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.Results {
+ flex: 1;
+}
+
+.Title {
+ margin-top: -0.375rem;
+ margin-bottom: 0.25rem;
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ letter-spacing: -0.0025em;
+ font-weight: 500;
+}
+
+.Description {
+ margin: 0 0 1.5rem;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ color: #65636d;
+}
+
+.Actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.FrameContainer {
+ width: 792px;
+ position: relative;
+}
+
+.Frame {
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+.FrameContainer.isDisabled {
+ cursor: wait;
+}
+
+/* Overlay to temporarily block interaction while the benchmark waits for idle/TTI */
+.FrameBlocker {
+ position: absolute;
+ inset: 0;
+ z-index: 100;
+ background: transparent;
+ touch-action: none;
+ cursor: wait;
+}
diff --git a/docs/components/BenchViewer/BenchViewer.tsx b/docs/components/BenchViewer/BenchViewer.tsx
new file mode 100644
index 000000000..8659edc07
--- /dev/null
+++ b/docs/components/BenchViewer/BenchViewer.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+import * as React from 'react';
+import { Dialog } from '@base-ui-components/react/dialog';
+import type { useDemo } from '@mui/internal-docs-infra/useDemo';
+import styles from './BenchViewer.module.css';
+
+const metricNames: Record = {
+ FCP: 'First Contentful Paint',
+ LCP: 'Largest Contentful Paint',
+ CLS: 'Cumulative Layout Shift',
+ INP: 'Interaction to Next Paint',
+ TTI: 'Time to Interactive',
+ TBT: 'Total Blocking Time',
+ 'long-task': 'Long Task',
+};
+
+const metricUnits: Record = {
+ FCP: 'ms',
+ LCP: 'ms',
+ INP: 'ms',
+ TTI: 'ms',
+ TBT: 'ms',
+ 'long-task': 'ms',
+};
+
+export function BenchViewer({
+ url,
+ demo,
+}: {
+ url: string | undefined;
+ demo: ReturnType;
+}) {
+ const { name } = demo.userProps;
+ const demoURL = new URL('.', url).toString().slice(0, -1); // remove filename and trailing slash
+ const lastAppIndex = demoURL ? demoURL.lastIndexOf('app/') : -1;
+ const demoPath = lastAppIndex !== -1 ? demoURL!.substring(lastAppIndex + 3) : demoURL || '';
+
+ const [open, setOpen] = React.useState(false);
+ const [benchShown, setBenchShown] = React.useState(false);
+ const [waitingForTTI, setWaitingForTTI] = React.useState(false);
+ const [metrics, setMetrics] = React.useState<
+ {
+ name: string;
+ rating?: string;
+ value: number;
+ metadata?: any;
+ }[]
+ >([]);
+
+ // reset benchmarks when reopening
+ React.useEffect(() => {
+ if (open) {
+ setMetrics([]);
+ setBenchShown(false);
+ setWaitingForTTI(false);
+ // request idle callback
+ window.requestIdleCallback(() => {
+ setBenchShown(true);
+ // Start waiting for TTI after FCP is received
+ setWaitingForTTI(true);
+ });
+ }
+ }, [open]);
+
+ React.useEffect(() => {
+ const callback = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) {
+ return; // our iframes are always same origin
+ }
+
+ if (event.data?.source !== 'docs-infra:bench') {
+ return;
+ }
+
+ if (event.data.type === 'web-vitals' && event.data.metric) {
+ // Stop waiting for TTI when it or TBT arrives (since TBT comes right after TTI)
+ if (event.data.metric.name === 'TTI' || event.data.metric.name === 'TBT') {
+ setWaitingForTTI(false);
+ }
+
+ setMetrics((prevMetrics) => [
+ ...prevMetrics,
+ {
+ name: event.data.metric.name,
+ rating: event.data.metric.rating,
+ value: event.data.metric.value,
+ metadata: event.data.metric.metadata,
+ },
+ ]);
+ }
+ };
+ window.addEventListener('message', callback, false);
+
+ return () => {
+ window.removeEventListener('message', callback);
+ };
+ }, []);
+
+ const frameRef = React.useRef(null);
+ // When interaction is re-enabled, ensure the iframe regains focus
+ React.useEffect(() => {
+ if (!waitingForTTI && open && benchShown) {
+ // Try focusing iframe element and its contentWindow for wheel/scroll
+ const el = frameRef.current;
+ // Defer to next frame to ensure overlay is removed
+ requestAnimationFrame(() => {
+ try {
+ el?.focus();
+ el?.contentWindow?.focus();
+ } catch {
+ // noop
+ }
+ });
+ }
+ }, [waitingForTTI, open, benchShown]);
+
+ const refreshFrame = React.useCallback(() => {
+ setMetrics([]);
+ setWaitingForTTI(false); // Reset waiting state first
+ setBenchShown(false); // Hide the iframe temporarily
+
+ if (frameRef.current) {
+ // Resetting the src forces a reload, which is important to get fresh metrics
+ const currentSrc = frameRef.current.src;
+ frameRef.current.src = currentSrc;
+ }
+
+ // Restart the benchmark process after a brief delay
+ window.requestIdleCallback(() => {
+ setBenchShown(true);
+ setWaitingForTTI(true); // Start waiting for TTI again
+ });
+ }, []);
+
+ const openInNewTab = React.useCallback(() => {
+ window.open(demoPath, '_blank', 'noopener,noreferrer');
+ }, [demoPath]);
+
+ return (
+
+
+ Start Benchmark
+
+
+
+
+
{name} Benchmark
+
+ {metrics.map((metric, index) => (
+
+
{metricNames[metric.name] || metric.name}:{' '}
+ {Math.round(metric.value)}
+ {metricUnits[metric.name] || ''} {metric.rating && `(${metric.rating})`}
+ {metric.name === 'long-task' && metric.metadata && (
+
+
Start: {Math.round(metric.metadata.startTime)}ms
+
Severity: {metric.metadata.severity}
+
Source: {metric.metadata.source || 'Unknown'}
+
+ )}
+
+ ))}
+ {waitingForTTI && (
+
+ Waiting for 5 seconds of JS Idle...
+
+ )}
+
+
+ Close
+
+
+
+
+
+ {open && benchShown ? (
+
+ ) : null}
+ {waitingForTTI ?
: null}
+
+
+
+
+
+ );
+}
diff --git a/docs/components/BenchViewer/index.ts b/docs/components/BenchViewer/index.ts
new file mode 100644
index 000000000..c2eef9806
--- /dev/null
+++ b/docs/components/BenchViewer/index.ts
@@ -0,0 +1 @@
+export * from './BenchViewer';
diff --git a/docs/components/DemoContentLoading/index.ts b/docs/components/DemoContentLoading/index.ts
new file mode 100644
index 000000000..0bc838df8
--- /dev/null
+++ b/docs/components/DemoContentLoading/index.ts
@@ -0,0 +1,2 @@
+// dog-food the demo content
+export * from '../../app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading';
diff --git a/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx b/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx
new file mode 100644
index 000000000..a9ad4c3f6
--- /dev/null
+++ b/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import * as React from 'react';
+import type { ContentProps } from '@mui/internal-docs-infra/CodeHighlighter/types';
+import { useDemo } from '@mui/internal-docs-infra/useDemo';
+import { LabeledSwitch } from '@/components/LabeledSwitch';
+import { Tabs } from '@/components/Tabs';
+import { CopyButton } from '@/components/CopyButton';
+import { Select } from '@/components/Select';
+import styles from '../../app/docs-infra/components/code-highlighter/demos/DemoContent.module.css';
+
+import '@wooorm/starry-night/style/light';
+import { BenchViewer } from '../BenchViewer';
+
+const variantNames: Record = {
+ CssModules: 'CSS Modules',
+};
+
+export function DemoPerformanceContent(props: ContentProps