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 ( +
+
+
{props.source}
+
+
+ ); +} 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 ( +
+
+ {children} +
+
+ ); +} 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 ? ( +