diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index f15958b416..dfd1a2b977 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -55,23 +55,16 @@ "./eslint-plugin": { "types": "./dist/eslint-plugin.d.ts", "default": "./dist/eslint-plugin.js" - }, - "./browser-poc": { - "types": "./dist/browser-poc.d.ts", - "default": "./dist/browser-poc.js" } }, "scripts": { "dev": "vp pack --watch", - "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && NODE_ENV=production vp pack && esbuild src/browser-poc.ts --bundle --format=iife --global-name=ReactDoctorBrowserPoc --platform=browser --target=es2022 --loader:.wasm=binary --outfile=dist/browser-poc.global.js", - "poc:browser": "vite --host 127.0.0.1", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && NODE_ENV=production vp pack", "typecheck": "tsc --noEmit", "test": "vp test run" }, "dependencies": { - "@oxc-parser/wasm": "^0.60.0", "agent-install": "0.0.5", - "bippy": "^0.5.39", "commander": "^14.0.3", "knip": "^6.10.0", "ora": "^9.4.0", diff --git a/packages/react-doctor/src/browser-poc.ts b/packages/react-doctor/src/browser-poc.ts deleted file mode 100644 index dc43edbe39..0000000000 --- a/packages/react-doctor/src/browser-poc.ts +++ /dev/null @@ -1,1083 +0,0 @@ -import { - _fiberRoots, - getDisplayName, - getNearestHostFiber, - getTimings, - getType, - instrument, - isCompositeFiber, - secure, - traverseFiber, - type Fiber, - type FiberRoot, -} from "bippy"; -import { initSync, parseSync } from "@oxc-parser/wasm/web/oxc_parser_wasm.js"; -import oxcParserWasmBytes from "@oxc-parser/wasm/web/oxc_parser_wasm_bg.wasm"; -import reactDoctorPlugin from "./plugin/index.js"; -import type { EsTreeNode, RuleVisitors } from "./plugin/types.js"; -import { - BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS, - BROWSER_POC_HOST_SELECTOR_MAX_COUNT, - ERROR_RULE_PENALTY, - PERFECT_SCORE, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, - WARNING_RULE_PENALTY, -} from "./constants.js"; - -export interface BrowserPocOptions { - dangerouslyRunInProduction?: boolean; - log?: boolean; -} - -export interface BrowserPocSourceLocation { - fileName: string; - lineNumber: number; - columnNumber: number | null; -} - -export interface BrowserPocRuntimeDiagnostic { - rule: string; - severity: "warning" | "error"; - message: string; - lineNumber: number | null; - columnNumber: number | null; -} - -export interface BrowserPocRuleFailure { - rule: string; - error: string; -} - -export interface BrowserPocRuleRunResult { - attempted: number; - completed: number; - failed: number; - failedRules: BrowserPocRuleFailure[]; -} - -export interface BrowserPocParseResult { - status: "parsed" | "parse-error" | "skipped" | "wasm-error"; - error: string | null; -} - -export interface BrowserPocComponentRecord { - id: number; - displayName: string; - tag: number; - instanceCount: number; - commitCount: number; - selfTime: number; - totalTime: number; - hookNames: string[]; - hostSelector: string | null; - sourceLocation: BrowserPocSourceLocation | null; - source: string | null; - parseResult: BrowserPocParseResult; - ruleRunResult: BrowserPocRuleRunResult; - diagnostics: BrowserPocRuntimeDiagnostic[]; -} - -interface BrowserPocAstNode extends EsTreeNode { - type: string; - name?: string; - value?: unknown; - raw?: string; - operator?: string; - argument?: BrowserPocAstNode; - object?: BrowserPocAstNode; - property?: BrowserPocAstNode; - callee?: BrowserPocAstNode; - id?: BrowserPocAstNode; - init?: BrowserPocAstNode; - body?: BrowserPocAstNode | BrowserPocAstNode[]; - program?: BrowserPocAstNode; - declarations?: BrowserPocAstNode[]; - elements?: Array; - expressions?: BrowserPocAstNode[]; - arguments?: BrowserPocAstNode[]; - params?: BrowserPocAstNode[]; - [key: string]: unknown; -} - -export interface BrowserPocScoreResult { - score: number; - label: string; -} - -export interface BrowserPocSnapshot { - isActive: boolean; - lastRendererID: number | null; - rootCount: number; - commitCount: number; - components: BrowserPocComponentRecord[]; - scoreResult: BrowserPocScoreResult; -} - -export interface BrowserPocController { - snapshot: () => BrowserPocSnapshot; - collectNow: () => BrowserPocSnapshot; -} - -declare global { - interface Window { - __reactDoctorBrowserPocOptions?: BrowserPocOptions; - reactDoctorBrowserPoc: BrowserPocController; - } -} - -const componentRecordsByType = new Map(); -const componentTypesByID = new Map(); -let nextComponentID = 1; -let lastRendererID: number | null = null; -let commitCount = 0; -let isActive = false; -let oxcWasmState: "pending" | "ready" | BrowserPocParseResult = "pending"; -const STACK_LOCATION_PATTERN = /\(?((?:[a-zA-Z][a-zA-Z\d+.-]*:\/\/|\/).+):(\d+):(\d+)\)?$/; - -const SKIPPED_PARSE_RESULT: BrowserPocParseResult = { - status: "skipped", - error: null, -}; - -const SKIPPED_RULE_RUN_RESULT: BrowserPocRuleRunResult = { - attempted: 0, - completed: 0, - failed: 0, - failedRules: [], -}; - -const initializeOxcWasm = (): BrowserPocParseResult | null => { - if (oxcWasmState === "ready") return null; - if (typeof oxcWasmState === "object") return oxcWasmState; - try { - initSync(oxcParserWasmBytes); - oxcWasmState = "ready"; - return null; - } catch (error) { - const wasmError: BrowserPocParseResult = { - status: "wasm-error", - error: error instanceof Error ? error.message : String(error), - }; - oxcWasmState = wasmError; - return wasmError; - } -}; - -interface BrowserPocFunctionSource { - code: string; - isTruncated: boolean; -} - -const getFunctionSource = (componentType: unknown): BrowserPocFunctionSource | null => { - if (typeof componentType !== "function") return null; - const source = Function.prototype.toString.call(componentType); - if (!source || source.includes("[native code]")) return null; - if (source.length > BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS) { - return { - code: source.slice(0, BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS), - isTruncated: true, - }; - } - return { code: source, isTruncated: false }; -}; - -const getSourceLocation = (fiber: Fiber): BrowserPocSourceLocation | null => { - const debugSource = fiber._debugSource; - if (debugSource) { - return { - fileName: debugSource.fileName, - lineNumber: debugSource.lineNumber, - columnNumber: debugSource.columnNumber ?? null, - }; - } - - const debugStack = fiber._debugStack?.stack; - if (!debugStack) return null; - for (const stackLine of debugStack.split("\n")) { - const match = STACK_LOCATION_PATTERN.exec(stackLine.trim()); - if (!match) continue; - const fileName = match[1]; - const lineNumber = Number(match[2]); - const columnNumber = Number(match[3]); - if (!fileName || !Number.isFinite(lineNumber)) continue; - if (fileName.includes("/node_modules/")) continue; - return { - fileName, - lineNumber, - columnNumber: Number.isFinite(columnNumber) ? columnNumber : null, - }; - } - return null; -}; - -const getHostSelector = (fiber: Fiber): string | null => { - const hostFiber = getNearestHostFiber(fiber); - const hostNode = hostFiber?.stateNode; - if (!(hostNode instanceof Element)) return null; - const selectorParts: string[] = []; - let currentElement: Element | null = hostNode; - while ( - currentElement && - selectorParts.length < BROWSER_POC_HOST_SELECTOR_MAX_COUNT && - currentElement !== document.documentElement - ) { - const id = currentElement.id ? `#${CSS.escape(currentElement.id)}` : ""; - const testId = currentElement.getAttribute("data-testid"); - const dataSelector = testId ? `[data-testid="${CSS.escape(testId)}"]` : ""; - const selector = id || dataSelector || currentElement.tagName.toLowerCase(); - selectorParts.unshift(selector); - if (id) break; - currentElement = currentElement.parentElement; - } - return selectorParts.join(" > "); -}; - -const getHookNames = (fiber: Fiber): string[] => { - const hookNames = fiber._debugHookTypes; - return Array.isArray(hookNames) ? [...new Set(hookNames)] : []; -}; - -const isAstNode = (value: unknown): value is BrowserPocAstNode => { - if (!value || typeof value !== "object") return false; - const maybeNode = value as Record; - return typeof maybeNode.type === "string"; -}; - -const walkAst = (node: unknown, visitor: (child: BrowserPocAstNode) => void): void => { - if (!isAstNode(node)) return; - visitor(node); - for (const [key, value] of Object.entries(node as Record)) { - if (key === "parent") continue; - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, visitor); - } - } else { - walkAst(value, visitor); - } - } -}; - -const isIdentifier = (node: BrowserPocAstNode | null | undefined, name: string): boolean => - node?.type === "Identifier" && node.name === name; - -const isHookCall = (node: BrowserPocAstNode, hookName: string): boolean => { - if (node.type !== "CallExpression") return false; - const callee = node.callee; - if (isIdentifier(callee, hookName)) return true; - return callee?.type === "MemberExpression" && isIdentifier(callee.property, hookName); -}; - -const isFetchCall = (node: BrowserPocAstNode): boolean => - node.type === "CallExpression" && isIdentifier(node.callee, "fetch"); - -const getEffectCallback = (node: BrowserPocAstNode): BrowserPocAstNode | null => { - if (!isHookCall(node, "useEffect")) return null; - const callback = node.arguments?.[0]; - if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") { - return callback; - } - return null; -}; - -const hasFetchCall = (node: BrowserPocAstNode): boolean => { - let didFindFetch = false; - walkAst(node, (child) => { - if (isFetchCall(child)) didFindFetch = true; - }); - return didFindFetch; -}; - -const createBrowserPocDiagnosticsFromAst = ( - program: BrowserPocAstNode, -): BrowserPocRuntimeDiagnostic[] => { - const diagnostics: BrowserPocRuntimeDiagnostic[] = []; - walkAst(program, (node) => { - const effectCallback = getEffectCallback(node); - if (!effectCallback) return; - if (hasFetchCall(effectCallback)) { - diagnostics.push({ - rule: "browser-poc/no-fetch-in-effect", - severity: "warning", - message: "Component has fetch() inside useEffect().", - lineNumber: null, - columnNumber: null, - }); - } - }); - return diagnostics; -}; - -const getLineColumn = ( - source: string, - offset: unknown, -): { lineNumber: number | null; columnNumber: number | null } => { - if (typeof offset !== "number" || !Number.isFinite(offset)) { - return { lineNumber: null, columnNumber: null }; - } - let lineNumber = 1; - let columnNumber = 1; - for (let index = 0; index < offset && index < source.length; index += 1) { - if (source[index] === "\n") { - lineNumber += 1; - columnNumber = 1; - } else { - columnNumber += 1; - } - } - return { lineNumber, columnNumber }; -}; - -const getAstChildren = (node: BrowserPocAstNode): BrowserPocAstNode[] => { - const children: BrowserPocAstNode[] = []; - for (const [key, value] of Object.entries(node)) { - if (key === "parent") continue; - if (Array.isArray(value)) { - for (const item of value) { - if (isAstNode(item)) children.push(item); - } - } else if (isAstNode(value)) { - children.push(value); - } - } - return children; -}; - -const visitAst = ( - node: BrowserPocAstNode, - visitors: RuleVisitors, - parent?: BrowserPocAstNode, -): void => { - node.parent = parent; - const enter = visitors[node.type]; - if (enter) enter(node); - for (const child of getAstChildren(node)) { - visitAst(child, visitors, node); - } - const exit = visitors[`${node.type}:exit`]; - if (exit) exit(node); -}; - -const runReactDoctorRules = ( - program: BrowserPocAstNode, - source: string, -): { diagnostics: BrowserPocRuntimeDiagnostic[]; ruleRunResult: BrowserPocRuleRunResult } => { - const diagnostics: BrowserPocRuntimeDiagnostic[] = []; - const failedRules: BrowserPocRuleFailure[] = []; - let completed = 0; - const ruleEntries = Object.entries(reactDoctorPlugin.rules); - - for (const [ruleName, rule] of ruleEntries) { - try { - const visitors = rule.create({ - getFilename: () => "app/component.tsx", - report: ({ node, message }) => { - const location = getLineColumn(source, node.start); - diagnostics.push({ - rule: `${reactDoctorPlugin.meta.name}/${ruleName}`, - severity: "warning", - message, - lineNumber: location.lineNumber, - columnNumber: location.columnNumber, - }); - }, - }); - visitAst(program, visitors); - completed += 1; - } catch (error) { - failedRules.push({ - rule: `${reactDoctorPlugin.meta.name}/${ruleName}`, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return { - diagnostics, - ruleRunResult: { - attempted: ruleEntries.length, - completed, - failed: failedRules.length, - failedRules, - }, - }; -}; - -const morphNode = (target: BrowserPocAstNode, replacement: BrowserPocAstNode): void => { - for (const key of Object.keys(target)) { - if (key === "start" || key === "end") continue; - delete target[key]; - } - for (const [key, value] of Object.entries(replacement)) { - if (key === "start" || key === "end") continue; - target[key] = value; - } -}; - -const SETTER_PREFIX = "set"; - -const buildSetterName = (valueName: string): string => - `${SETTER_PREFIX}${valueName.charAt(0).toUpperCase()}${valueName.slice(1)}`; - -const JSX_CALLEE_NAMES = new Set(["jsx", "jsxs", "_jsx", "_jsxs", "createElement"]); - -const getNodeCalleeName = (node: BrowserPocAstNode): string | null => { - const callee = node.callee; - if (!callee) return null; - if (callee.type === "Identifier") return callee.name ?? null; - if (callee.type === "MemberExpression" && callee.property?.type === "Identifier") { - return callee.property.name ?? null; - } - return null; -}; - -const buildJsxName = (elementType: BrowserPocAstNode): BrowserPocAstNode | null => { - if (elementType.type === "Literal" && typeof elementType.value === "string") { - return { type: "JSXIdentifier", name: elementType.value } as BrowserPocAstNode; - } - if (elementType.type === "Identifier") { - return { type: "JSXIdentifier", name: elementType.name } as BrowserPocAstNode; - } - if (elementType.type === "MemberExpression" && elementType.property?.type === "Identifier") { - const objectJsxName = buildJsxName(elementType.object as BrowserPocAstNode); - if (!objectJsxName) return null; - return { - type: "JSXMemberExpression", - object: objectJsxName, - property: { type: "JSXIdentifier", name: elementType.property.name }, - } as BrowserPocAstNode; - } - return null; -}; - -const buildJsxAttributeValue = (valueNode: BrowserPocAstNode): BrowserPocAstNode | null => { - if (valueNode.type === "Literal" && typeof valueNode.value === "string") { - return valueNode; - } - if (valueNode.type === "Literal" && valueNode.value === true) { - return null; - } - return { - type: "JSXExpressionContainer", - expression: valueNode, - } as BrowserPocAstNode; -}; - -const JSX_NODE_TYPES = new Set(["JSXElement", "JSXFragment", "JSXText", "JSXExpressionContainer"]); - -const wrapJsxChild = (child: BrowserPocAstNode): BrowserPocAstNode => { - if (JSX_NODE_TYPES.has(child.type)) return child; - if (child.type === "Literal" && typeof child.value === "string") { - return { type: "JSXText", value: child.value, raw: String(child.value) } as BrowserPocAstNode; - } - return { - type: "JSXExpressionContainer", - expression: child, - } as BrowserPocAstNode; -}; - -const isFragmentType = (elementType: BrowserPocAstNode): boolean => { - if (elementType.type === "Identifier" && elementType.name === "Fragment") return true; - if ( - elementType.type === "MemberExpression" && - elementType.property?.type === "Identifier" && - elementType.property.name === "Fragment" - ) { - return true; - } - return false; -}; - -const reconstructJsx = (node: BrowserPocAstNode): void => { - if (node.type !== "CallExpression") return; - const calleeName = getNodeCalleeName(node); - if (!calleeName || !JSX_CALLEE_NAMES.has(calleeName)) return; - - const nodeArguments = node.arguments; - if (!Array.isArray(nodeArguments) || nodeArguments.length < 1) return; - - const elementType = nodeArguments[0] as BrowserPocAstNode; - const isFragment = isFragmentType(elementType); - - if (!isFragment) { - const jsxName = buildJsxName(elementType); - if (!jsxName) return; - } - - const jsxName = isFragment ? null : buildJsxName(elementType); - if (!isFragment && !jsxName) return; - - const attributes: BrowserPocAstNode[] = []; - const children: BrowserPocAstNode[] = []; - const propsArg = nodeArguments[1] as BrowserPocAstNode | undefined; - const isCreateElement = calleeName === "createElement"; - - if (propsArg && propsArg.type === "ObjectExpression" && Array.isArray(propsArg.properties)) { - for (const property of propsArg.properties as BrowserPocAstNode[]) { - if (property.type === "SpreadElement" || property.type === "RestElement") { - attributes.push({ - type: "JSXSpreadAttribute", - argument: property.argument, - } as BrowserPocAstNode); - continue; - } - if (property.type !== "Property") continue; - - const keyNode = property.key as BrowserPocAstNode | undefined; - if (!keyNode) continue; - const keyName = - keyNode.type === "Identifier" - ? keyNode.name - : keyNode.type === "Literal" - ? String(keyNode.value) - : null; - if (!keyName) continue; - - const propertyValue = property.value as BrowserPocAstNode; - - if (keyName === "children") { - if (propertyValue.type === "ArrayExpression" && Array.isArray(propertyValue.elements)) { - for (const element of propertyValue.elements as BrowserPocAstNode[]) { - if (element) children.push(wrapJsxChild(element)); - } - } else { - children.push(wrapJsxChild(propertyValue)); - } - continue; - } - - const attributeValue = buildJsxAttributeValue(propertyValue); - attributes.push({ - type: "JSXAttribute", - name: { type: "JSXIdentifier", name: keyName }, - value: attributeValue, - } as unknown as BrowserPocAstNode); - } - } - - if (isCreateElement) { - for (let argumentIndex = 2; argumentIndex < nodeArguments.length; argumentIndex++) { - children.push(wrapJsxChild(nodeArguments[argumentIndex] as BrowserPocAstNode)); - } - } else if (nodeArguments.length >= 3) { - const keyArg = nodeArguments[2] as BrowserPocAstNode; - if (keyArg && keyArg.type !== "Identifier") { - attributes.push({ - type: "JSXAttribute", - name: { type: "JSXIdentifier", name: "key" }, - value: buildJsxAttributeValue(keyArg), - } as unknown as BrowserPocAstNode); - } - } - - if (isFragment) { - morphNode(node, { - type: "JSXFragment", - openingFragment: { type: "JSXOpeningFragment" }, - closingFragment: { type: "JSXClosingFragment" }, - children, - } as unknown as BrowserPocAstNode); - return; - } - - const hasChildren = children.length > 0; - const openingElement = { - type: "JSXOpeningElement", - name: jsxName, - attributes, - selfClosing: !hasChildren, - } as unknown as BrowserPocAstNode; - - morphNode(node, { - type: "JSXElement", - openingElement, - closingElement: hasChildren - ? ({ type: "JSXClosingElement", name: { ...jsxName } } as unknown as BrowserPocAstNode) - : null, - children, - } as unknown as BrowserPocAstNode); -}; - -const normalizeMinifiedAst = (node: BrowserPocAstNode, displayName: string | null): void => { - for (const [key, value] of Object.entries(node)) { - if (key === "parent" || key === "type" || key === "start" || key === "end") continue; - if (Array.isArray(value)) { - for (const item of value) { - if (isAstNode(item)) normalizeMinifiedAst(item, displayName); - } - } else if (isAstNode(value)) { - normalizeMinifiedAst(value as BrowserPocAstNode, displayName); - } - } - - if ( - (node.type === "CallExpression" || node.type === "NewExpression") && - node.callee?.type === "SequenceExpression" - ) { - const expressions = node.callee.expressions; - if (Array.isArray(expressions) && expressions.length > 0) { - node.callee = expressions[expressions.length - 1] as BrowserPocAstNode; - } - } - - if ( - node.type === "StringLiteral" || - node.type === "NumericLiteral" || - node.type === "BooleanLiteral" || - node.type === "NullLiteral" - ) { - const preservedValue = node.type === "NullLiteral" ? null : node.value; - const preservedRaw = (node.raw as string | undefined) ?? String(preservedValue); - morphNode(node, { - type: "Literal", - value: preservedValue, - raw: preservedRaw, - } as BrowserPocAstNode); - } - - if ( - node.type === "UnaryExpression" && - node.operator === "!" && - node.argument?.type === "Literal" && - typeof node.argument.value === "number" - ) { - const numericValue = node.argument.value as number; - if (numericValue === 0 || numericValue === 1) { - morphNode(node, { - type: "Literal", - value: numericValue === 0, - raw: numericValue === 0 ? "true" : "false", - } as BrowserPocAstNode); - } - } - - if ( - node.type === "UnaryExpression" && - node.operator === "void" && - node.argument?.type === "Literal" && - node.argument.value === 0 - ) { - morphNode(node, { type: "Identifier", name: "undefined" } as BrowserPocAstNode); - } - - if (node.type === "ReturnStatement" && node.argument?.type === "SequenceExpression") { - const sequenceExpressions = node.argument.expressions; - if (Array.isArray(sequenceExpressions) && sequenceExpressions.length > 1) { - const lastExpression = sequenceExpressions[ - sequenceExpressions.length - 1 - ] as BrowserPocAstNode; - const sideEffectStatements = sequenceExpressions.slice(0, -1).map( - (expression) => - ({ - type: "ExpressionStatement", - expression, - }) as unknown as BrowserPocAstNode, - ); - node.argument = lastExpression; - node._hoistedStatements = sideEffectStatements; - } - } - - if ((node.type === "BlockStatement" || node.type === "Program") && Array.isArray(node.body)) { - const expandedBody: BrowserPocAstNode[] = []; - let didExpand = false; - for (const statement of node.body as BrowserPocAstNode[]) { - if ( - Array.isArray(statement._hoistedStatements) && - (statement._hoistedStatements as BrowserPocAstNode[]).length > 0 - ) { - expandedBody.push(...(statement._hoistedStatements as BrowserPocAstNode[])); - delete statement._hoistedStatements; - didExpand = true; - } - const statementExpression = statement.expression as BrowserPocAstNode | undefined; - if ( - statement.type === "ExpressionStatement" && - statementExpression?.type === "SequenceExpression" - ) { - const expressions = statementExpression.expressions; - if (Array.isArray(expressions)) { - for (const expression of expressions as BrowserPocAstNode[]) { - expandedBody.push({ - type: "ExpressionStatement", - expression, - } as unknown as BrowserPocAstNode); - } - didExpand = true; - continue; - } - } - expandedBody.push(statement); - } - if (didExpand) node.body = expandedBody; - } - - if ( - node.type === "VariableDeclaration" && - Array.isArray(node.declarations) && - node.declarations.length > 1 && - node.parent && - isAstNode(node.parent) && - (node.parent.type === "BlockStatement" || node.parent.type === "Program") - ) { - node._splitDeclarations = (node.declarations as BrowserPocAstNode[]).map( - (declarator) => - ({ - type: "VariableDeclaration", - kind: node.kind, - declarations: [declarator], - }) as unknown as BrowserPocAstNode, - ); - } - - if ((node.type === "BlockStatement" || node.type === "Program") && Array.isArray(node.body)) { - let didSplit = false; - const splitBody: BrowserPocAstNode[] = []; - for (const statement of node.body as BrowserPocAstNode[]) { - if (statement.type === "VariableDeclaration" && Array.isArray(statement._splitDeclarations)) { - splitBody.push(...(statement._splitDeclarations as BrowserPocAstNode[])); - delete statement._splitDeclarations; - didSplit = true; - } else { - splitBody.push(statement); - } - } - if (didSplit) node.body = splitBody; - } - - const arrowBody = node.type === "ArrowFunctionExpression" ? node.body : null; - if ( - arrowBody && - !Array.isArray(arrowBody) && - isAstNode(arrowBody) && - arrowBody.type === "SequenceExpression" && - Array.isArray(arrowBody.expressions) - ) { - const arrowExpressions = arrowBody.expressions as BrowserPocAstNode[]; - if (arrowExpressions.length > 1) { - const lastArrowExpression = arrowExpressions[arrowExpressions.length - 1]; - const leadingStatements = arrowExpressions - .slice(0, -1) - .map( - (expression) => - ({ type: "ExpressionStatement", expression }) as unknown as BrowserPocAstNode, - ); - node.body = { - type: "BlockStatement", - body: [ - ...leadingStatements, - { - type: "ReturnStatement", - argument: lastArrowExpression, - } as unknown as BrowserPocAstNode, - ], - } as unknown as BrowserPocAstNode; - } - } - - if ( - node.type === "MemberExpression" && - node.computed === true && - node.property?.type === "Literal" && - typeof node.property.value === "string" && - /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(node.property.value) - ) { - node.computed = false; - node.property = { - type: "Identifier", - name: node.property.value, - } as BrowserPocAstNode; - } - - reconstructJsx(node); - - if (displayName) { - const uppercasedDisplayName = displayName.charAt(0).toUpperCase() + displayName.slice(1); - - if ( - node.type === "VariableDeclarator" && - node.id?.type === "Identifier" && - node.id.name === "__reactDoctorComponent" && - node.init && - (node.init.type === "FunctionExpression" || node.init.type === "ArrowFunctionExpression") - ) { - node.id.name = uppercasedDisplayName; - } - - if ( - node.type === "FunctionDeclaration" && - node.id?.type === "Identifier" && - node.id.name && - /^[a-z]/.test(node.id.name) && - node.params && - Array.isArray(node.params) && - node.params.length <= 2 - ) { - node.id.name = uppercasedDisplayName; - } - } -}; - -const renameMinifiedSetters = (program: BrowserPocAstNode): void => { - const setterRenames = new Map(); - - walkAst(program, (node) => { - if (node.type !== "VariableDeclarator" || node.id?.type !== "ArrayPattern") return; - const initNode = node.init; - if (!initNode || initNode.type !== "CallExpression") return; - - const callee = initNode.callee; - const isUseStateCall = - (callee?.type === "Identifier" && callee.name === "useState") || - (callee?.type === "MemberExpression" && - callee.property?.type === "Identifier" && - callee.property.name === "useState"); - if (!isUseStateCall) return; - - const elements = node.id.elements; - if (!Array.isArray(elements) || elements.length < 2) return; - const valueElement = elements[0] as BrowserPocAstNode | null; - const setterElement = elements[1] as BrowserPocAstNode | null; - if ( - valueElement?.type !== "Identifier" || - setterElement?.type !== "Identifier" || - !valueElement.name || - !setterElement.name - ) { - return; - } - if (/^set[A-Z]/.test(setterElement.name)) return; - - const newSetterName = buildSetterName(valueElement.name); - setterRenames.set(setterElement.name, newSetterName); - setterElement.name = newSetterName; - }); - - if (setterRenames.size === 0) return; - - walkAst(program, (node) => { - if (node.type !== "Identifier" || !node.name) return; - const newName = setterRenames.get(node.name); - if (newName) node.name = newName; - }); -}; - -const parseComponentSource = ( - functionSource: BrowserPocFunctionSource | null, - displayName: string | null, -): { - diagnostics: BrowserPocRuntimeDiagnostic[]; - parseResult: BrowserPocParseResult; - ruleRunResult: BrowserPocRuleRunResult; -} => { - if (!functionSource) { - return { - diagnostics: [], - parseResult: SKIPPED_PARSE_RESULT, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - if (functionSource.isTruncated) { - return { - diagnostics: [], - parseResult: { status: "skipped", error: null }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - const rawSource = functionSource.code; - const wasmError = initializeOxcWasm(); - if (wasmError) { - return { diagnostics: [], parseResult: wasmError, ruleRunResult: SKIPPED_RULE_RUN_RESULT }; - } - try { - const wrappedSource = `"use client";\nconst __reactDoctorComponent = ${rawSource};`; - const result = parseSync(wrappedSource, { - sourceFilename: "app/component.tsx", - }); - if (result.errors.length > 0) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: result.errors.map((error) => error.message).join("\n"), - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - const program = result.program as unknown; - if (!isAstNode(program)) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: "OXC returned a non-ESTree program.", - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } - normalizeMinifiedAst(program, displayName); - renameMinifiedSetters(program); - const reactDoctorRuleResult = runReactDoctorRules(program, wrappedSource); - const browserPocDiagnostics = createBrowserPocDiagnosticsFromAst(program).filter( - (diagnostic) => - diagnostic.rule !== "browser-poc/no-fetch-in-effect" || - !reactDoctorRuleResult.diagnostics.some( - (reactDoctorDiagnostic) => - reactDoctorDiagnostic.rule === `${reactDoctorPlugin.meta.name}/no-fetch-in-effect`, - ), - ); - return { - diagnostics: [...reactDoctorRuleResult.diagnostics, ...browserPocDiagnostics], - parseResult: { - status: "parsed", - error: null, - }, - ruleRunResult: reactDoctorRuleResult.ruleRunResult, - }; - } catch (error) { - return { - diagnostics: [], - parseResult: { - status: "parse-error", - error: error instanceof Error ? error.message : String(error), - }, - ruleRunResult: SKIPPED_RULE_RUN_RESULT, - }; - } -}; - -const getRecord = (fiber: Fiber): BrowserPocComponentRecord | null => { - const componentType = getType(fiber.type) ?? fiber.type; - if (!componentType) return null; - const existingRecord = componentRecordsByType.get(componentType); - if (existingRecord) return existingRecord; - - const functionSource = getFunctionSource(componentType); - const displayName = getDisplayName(componentType) ?? "Anonymous"; - const parsed = parseComponentSource(functionSource, displayName); - const id = nextComponentID; - nextComponentID += 1; - const record: BrowserPocComponentRecord = { - id, - displayName, - tag: fiber.tag, - instanceCount: 0, - commitCount: 0, - selfTime: 0, - totalTime: 0, - hookNames: [], - hostSelector: null, - sourceLocation: null, - source: functionSource?.code ?? null, - parseResult: parsed.parseResult, - ruleRunResult: parsed.ruleRunResult, - diagnostics: parsed.diagnostics, - }; - componentRecordsByType.set(componentType, record); - componentTypesByID.set(id, componentType); - return record; -}; - -const collectFiber = (fiber: Fiber): void => { - if (!isCompositeFiber(fiber)) return; - const record = getRecord(fiber); - if (!record) return; - record.instanceCount += 1; - record.commitCount += 1; - record.hookNames = getHookNames(fiber); - record.hostSelector = getHostSelector(fiber); - record.sourceLocation = getSourceLocation(fiber); - const timings = getTimings(fiber); - record.selfTime = timings.selfTime; - record.totalTime = timings.totalTime; -}; - -const collectRoot = (rendererID: number | null, root: FiberRoot): void => { - lastRendererID = rendererID; - commitCount += 1; - traverseFiber(root.current, collectFiber); -}; - -const getScoreLabel = (score: number): string => { - if (score >= SCORE_GOOD_THRESHOLD) return "Great"; - if (score >= SCORE_OK_THRESHOLD) return "Needs work"; - return "Critical"; -}; - -const calculateBrowserPocScore = ( - diagnostics: BrowserPocRuntimeDiagnostic[], -): BrowserPocScoreResult => { - const errorRules = new Set(); - const warningRules = new Set(); - for (const diagnostic of diagnostics) { - if (diagnostic.severity === "error") { - errorRules.add(diagnostic.rule); - } else { - warningRules.add(diagnostic.rule); - } - } - const penalty = errorRules.size * ERROR_RULE_PENALTY + warningRules.size * WARNING_RULE_PENALTY; - const score = Math.max(0, Math.round(PERFECT_SCORE - penalty)); - return { score, label: getScoreLabel(score) }; -}; - -const buildSnapshot = (): BrowserPocSnapshot => { - const components: BrowserPocComponentRecord[] = []; - const allDiagnostics: BrowserPocRuntimeDiagnostic[] = []; - for (const componentID of componentTypesByID.keys()) { - const componentType = componentTypesByID.get(componentID); - const record = componentRecordsByType.get(componentType); - if (record) { - components.push(record); - allDiagnostics.push(...record.diagnostics); - } - } - return { - isActive, - lastRendererID, - rootCount: _fiberRoots.size, - commitCount, - components, - scoreResult: calculateBrowserPocScore(allDiagnostics), - }; -}; - -const collectNow = (): BrowserPocSnapshot => { - for (const root of _fiberRoots) { - collectRoot(lastRendererID, root); - } - return buildSnapshot(); -}; - -export const startBrowserPoc = (options: BrowserPocOptions = {}): BrowserPocController => { - const controller: BrowserPocController = { - snapshot: buildSnapshot, - collectNow, - }; - - instrument( - secure( - { - name: "react-doctor-browser-poc", - onActive: () => { - isActive = true; - }, - onCommitFiberRoot: (rendererID, root) => { - collectRoot(rendererID, root); - if (options.log) console.log("[react-doctor/browser-poc]", buildSnapshot()); - }, - }, - { - dangerouslyRunInProduction: options.dangerouslyRunInProduction ?? false, - }, - ), - ); - - window.reactDoctorBrowserPoc = controller; - return controller; -}; - -if (typeof window !== "undefined") { - startBrowserPoc(window.__reactDoctorBrowserPocOptions); -} diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index e47eda023c..b0d5e56f52 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -6,10 +6,6 @@ export const MILLISECONDS_PER_SECOND = 1000; export const ERROR_PREVIEW_LENGTH_CHARS = 200; -export const BROWSER_POC_FUNCTION_SOURCE_MAX_CHARS = 20_000; - -export const BROWSER_POC_HOST_SELECTOR_MAX_COUNT = 4; - export const PERFECT_SCORE = 100; export const SCORE_GOOD_THRESHOLD = 75; diff --git a/packages/react-doctor/src/wasm.d.ts b/packages/react-doctor/src/wasm.d.ts deleted file mode 100644 index 5e186b62b7..0000000000 --- a/packages/react-doctor/src/wasm.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.wasm" { - const wasmBytes: Uint8Array; - export default wasmBytes; -} diff --git a/packages/react-doctor/tests/fixtures/browser-poc/app.tsx b/packages/react-doctor/tests/fixtures/browser-poc/app.tsx deleted file mode 100644 index 266cccbaf2..0000000000 --- a/packages/react-doctor/tests/fixtures/browser-poc/app.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; - -interface DemoUserCardProps { - userID: number; -} - -interface BrowserPocDemoState { - componentCount: number; - diagnosticCount: number; - names: string[]; -} - -const DemoUserCard = ({ userID }: DemoUserCardProps) => { - const [name, setName] = React.useState("loading"); - - React.useEffect(() => { - fetch("data:application/json,%7B%7D").then(() => { - setName(`user ${userID}`); - }); - }, [userID]); - - return ( -
-

{name}

-

This component intentionally fetches inside an effect for the POC.

-
- ); -}; - -const readSnapshot = (): BrowserPocDemoState => { - const snapshot = window.reactDoctorBrowserPoc.collectNow(); - const diagnosticCount = snapshot.components.reduce( - (totalCount, component) => totalCount + component.diagnostics.length, - 0, - ); - return { - componentCount: snapshot.components.length, - diagnosticCount, - names: snapshot.components.map((component) => component.displayName), - }; -}; - -const App = () => { - const [snapshot, setSnapshot] = React.useState(null); - - return ( -
-

React Doctor Browser POC

- - -
{JSON.stringify(snapshot, null, 2)}
-
- ); -}; - -const rootElement = document.getElementById("root"); - -if (!rootElement) { - throw new Error("Missing root element"); -} - -createRoot(rootElement).render(); diff --git a/packages/react-doctor/tests/fixtures/browser-poc/index.html b/packages/react-doctor/tests/fixtures/browser-poc/index.html deleted file mode 100644 index d74d375b8b..0000000000 --- a/packages/react-doctor/tests/fixtures/browser-poc/index.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - React Doctor Browser POC - - - -
- - - - diff --git a/packages/react-doctor/vite.config.ts b/packages/react-doctor/vite.config.ts index bd0cdf0064..2c66a9aaf9 100644 --- a/packages/react-doctor/vite.config.ts +++ b/packages/react-doctor/vite.config.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); -const WASM_FILE_SUFFIX = ".wasm"; const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { version: string; @@ -44,18 +43,6 @@ const copySkillToDist = () => { }; export default defineConfig({ - plugins: [ - { - name: "react-doctor-wasm-binary-loader", - enforce: "pre", - load: (id) => { - if (!id.endsWith(WASM_FILE_SUFFIX)) return null; - const base64 = fs.readFileSync(id).toString("base64"); - return `const binary = atob(${JSON.stringify(base64)}); -export default Uint8Array.from(binary, (character) => character.charCodeAt(0));`; - }, - }, - ], pack: [ { entry: { cli: "./src/cli/index.ts" }, @@ -104,13 +91,6 @@ export default Uint8Array.from(binary, (character) => character.charCodeAt(0));` VERSION: process.env.VERSION ?? packageJson.version, }, }, - { - entry: { "browser-poc": "./src/browser-poc.ts" }, - dts: true, - target: "es2022", - platform: "browser", - fixedExtension: false, - }, ], test: { testTimeout: 30_000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 429826a1c7..ecbb80d43a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,15 +36,9 @@ importers: packages/react-doctor: dependencies: - '@oxc-parser/wasm': - specifier: ^0.60.0 - version: 0.60.0 agent-install: specifier: 0.0.5 version: 0.0.5 - bippy: - specifier: ^0.5.39 - version: 0.5.39(react@19.2.5) commander: specifier: ^14.0.3 version: 14.0.3 @@ -876,10 +870,6 @@ packages: cpu: [x64] os: [win32] - '@oxc-parser/wasm@0.60.0': - resolution: {integrity: sha512-Dkf9/D87WGBCW3L0+1DtpAfL4SrNsgeRvxwjpKCtbH7Kf6K+pxrT0IridaJfmWKu1Ml+fDvj+7HEyBcfUC/TXQ==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - '@oxc-project/runtime@0.127.0': resolution: {integrity: sha512-UQYLxAhDDPHm++szfa4z0RTdcPq5vaywrAoEA2n1YaAKeanXQdjHsoT6x1gP3U97RN8LZ7yHsSOrKPCcA6mCqw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -890,9 +880,6 @@ packages: '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} - '@oxc-project/types@0.60.0': - resolution: {integrity: sha512-prhfNnb3ATFHOCv7mzKFfwLij5RzoUz6Y1n525ZhCEqfq5wreCXL+DyVoq3ShukPo7q45ZjYIdjFUgjj+WKzng==} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] @@ -1813,11 +1800,6 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - bippy@0.5.39: - resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==} - peerDependencies: - react: '>=17.0.1' - brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -3583,18 +3565,12 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.128.0': optional: true - '@oxc-parser/wasm@0.60.0': - dependencies: - '@oxc-project/types': 0.60.0 - '@oxc-project/runtime@0.127.0': {} '@oxc-project/types@0.127.0': {} '@oxc-project/types@0.128.0': {} - '@oxc-project/types@0.60.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -4133,10 +4109,6 @@ snapshots: dependencies: is-windows: 1.0.2 - bippy@0.5.39(react@19.2.5): - dependencies: - react: 19.2.5 - brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2