From 7070bd63a42aa57f47f677ba22106e63815e7d72 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 23 Sep 2026 12:23:36 +0200 Subject: [PATCH 01/84] perf(bundler-plugins): Use fast component annotation for HTML injection mode (#24440) Stacked on #24437. When `reactComponentAnnotation._experimentalInjectIntoHtml` was set, annotation always went through Babel, even on Vite 8. This PR ports that mode to the fast walker, so every Rollup and Vite build now uses the fast path. The next step moves Webpack and the Next.js Turbopack loader over, and after that Babel can be removed. HTML mode finds components the same way as the normal mode. The difference is in how it handles JSX. It adds only `data-sentry-component`, and only to the first HTML elements below each component root, looking through fragments and component elements to find them. The port keeps two Babel quirks so the output stays the same: element names starting with `_` or `$` count as HTML elements, and anonymous classes get `data-sentry-component=""`. The new tests compare the walker with the Babel HTML plugin on both `@babel/parser` and `oxc-parser` output. Ref #24415 --- .../vite8/component-annotation-next.test.ts | 6 +- .../src/core/component-annotation-oxc-jsx.ts | 102 +++++++++- .../src/core/component-annotation-oxc-walk.ts | 37 +++- .../src/core/component-annotation-oxc.ts | 5 +- packages/bundler-plugins/src/rollup/index.ts | 48 +++-- .../core/component-annotation-oxc.test.ts | 187 +++++++++++++++++- .../test/rollup/public-api.test.ts | 18 +- 7 files changed, 350 insertions(+), 53 deletions(-) diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts index ad52e996a9f5..41b172e14dd2 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts @@ -34,13 +34,13 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { "data-sentry-component": "App", children: [/* @__PURE__ */ jsxDEV(ComponentA, {}, void 0, false, { fileName: _jsxFileName, - lineNumber: 4, + lineNumber: 6, columnNumber: 7 }, this), ";"] }, void 0, true, { fileName: _jsxFileName, - lineNumber: 3, - columnNumber: 10 + lineNumber: 5, + columnNumber: 5 }, this); } console.log(App()); diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts index 78fd40d99ae6..5e5c2cd4c638 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts @@ -16,6 +16,28 @@ import type { import { isAstNode, isObjectLike } from './component-annotation-oxc-ast'; const UNKNOWN_ELEMENT_NAME = 'unknown'; +const REACT_NATIVE_ELEMENTS = new Set([ + 'Image', + 'Text', + 'View', + 'ScrollView', + 'TextInput', + 'TouchableOpacity', + 'TouchableHighlight', + 'TouchableWithoutFeedback', + 'FlatList', + 'SectionList', + 'ActivityIndicator', + 'Button', + 'Switch', + 'Modal', + 'SafeAreaView', + 'StatusBar', + 'KeyboardAvoidingView', + 'RefreshControl', + 'Picker', + 'Slider', +]); const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const; const WEB_ATTRIBUTE_NAME_SET = new Set(WEB_ATTRIBUTE_NAMES); @@ -140,20 +162,79 @@ export function addPendingAttributes( return; } - const insertion = - pendingInsertion ?? - insertionsByOffset - .set(offset, { - offset, - attributeValues: new Map(), - }) - .get(offset); + const insertion = getOrCreateInsertion(insertionsByOffset, offset); for (const [name, value] of attributes) { - insertion?.attributeValues.set(name, value); + insertion.attributeValues.set(name, value); } } +/** + * Returns `true` when the element is an HTML element, because HTML mode only + * annotates the first HTML elements below a component root. + */ +export function addPendingHtmlAttribute( + code: string, + openingElement: JSXOpeningElementNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + insertionsByOffset: Map, +): boolean { + if (isReactFragment(openingElement, fragmentContext)) { + return false; + } + + const elementName = getJSXName(openingElement.name); + + if (!isHtmlElement(elementName)) { + return false; + } + + if (ignoredComponents.includes(componentName) || ignoredComponents.includes(elementName)) { + return true; + } + + const offset = getInsertionOffset(code, openingElement); + if (offset === null) { + return true; + } + + if ( + getExistingAttributeNames(openingElement).has(WEB_COMPONENT_NAME) || + insertionsByOffset.get(offset)?.attributeValues.has(WEB_COMPONENT_NAME) + ) { + return true; + } + + getOrCreateInsertion(insertionsByOffset, offset).attributeValues.set(WEB_COMPONENT_NAME, componentName); + + return true; +} + +function isHtmlElement(elementName: string): boolean { + if (elementName === UNKNOWN_ELEMENT_NAME) { + return false; + } + + if (elementName.charAt(0) === elementName.charAt(0).toLowerCase()) { + return true; + } + + return REACT_NATIVE_ELEMENTS.has(elementName); +} + +function getOrCreateInsertion(insertionsByOffset: Map, offset: number): PendingInsertion { + let insertion = insertionsByOffset.get(offset); + + if (!insertion) { + insertion = { offset, attributeValues: new Map() }; + insertionsByOffset.set(offset, insertion); + } + + return insertion; +} + export function toAttributeInsertions(insertionsByOffset: Map): AttributeInsertion[] { return [...insertionsByOffset.values()].map(({ offset, attributeValues }) => ({ offset, @@ -181,7 +262,8 @@ function getOrderedAttributes(attributeValues: ReadonlyMap): Com for (const name of WEB_ATTRIBUTE_NAMES) { const value = attributeValues.get(name); - if (value) { + // HTML mode writes an empty component name for anonymous classes. + if (value !== undefined) { attributes.push([name, value]); } } diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts index 7425d145dd4e..9386b0489fda 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts @@ -1,6 +1,7 @@ import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-oxc-ast'; import { addPendingAttributes, + addPendingHtmlAttribute, getStringName, isJSXElement, isJSXRoot, @@ -16,6 +17,7 @@ export function collectOxcComponentAnnotationInsertions( ast: AstNode, ignoredComponents: string[], sourceFileName: string, + injectIntoHtml: boolean, ): AttributeInsertion[] { const fragmentContext = collectFragmentContext(ast); const components = collectComponentJSXRoots(ast); @@ -23,13 +25,46 @@ export function collectOxcComponentAnnotationInsertions( for (const component of components) { for (const root of component.roots) { - processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset); + if (injectIntoHtml) { + processHtmlJSX(code, root, component.name, ignoredComponents, fragmentContext, insertionsByOffset); + } else { + processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset); + } } } return toAttributeInsertions(insertionsByOffset); } +function processHtmlJSX( + code: string, + node: JSXRootNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + insertionsByOffset: Map }>, +): void { + if ( + isJSXElement(node) && + addPendingHtmlAttribute( + code, + node.openingElement, + componentName, + ignoredComponents, + fragmentContext, + insertionsByOffset, + ) + ) { + return; + } + + for (const child of node.children ?? []) { + if (isJSXRoot(child)) { + processHtmlJSX(code, child, componentName, ignoredComponents, fragmentContext, insertionsByOffset); + } + } +} + function getJSXRootsFromReturnArgument(argument: unknown): JSXRootNode[] { if (isJSXRoot(argument)) { return [argument]; diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc.ts b/packages/bundler-plugins/src/core/component-annotation-oxc.ts index 8bb27f8a7963..56da59a7e601 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc.ts @@ -101,6 +101,7 @@ async function annotateWithOxcParser( id: string, ignoredComponents: string[], parseAstAsync: ParseAstAsync, + injectIntoHtml: boolean, meta?: ComponentAnnotationTransformMeta, ): Promise { const idWithoutQueryAndHash = stripQueryAndHashFromPath(id); @@ -129,6 +130,7 @@ async function annotateWithOxcParser( ast, ignoredComponents, path.basename(idWithoutQueryAndHash), + injectIntoHtml, ); if (insertions.length === 0) { @@ -159,6 +161,7 @@ async function annotateWithOxcParser( export function createOxcComponentNameAnnotateHooks( ignoredComponents: string[], getParseAstAsync: () => Promise, + injectIntoHtml = false, ): { transform( code: string, @@ -175,7 +178,7 @@ export function createOxcComponentNameAnnotateHooks( return undefined; } - return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, meta); + return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, injectIntoHtml, meta); } catch { return undefined; } diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 9b41176701d9..8bc88720353b 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -168,31 +168,29 @@ export function _rollupPluginInternal( !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, ) : undefined; - const transformFastAnnotations = - options.reactComponentAnnotation?.enabled && !options.reactComponentAnnotation?._experimentalInjectIntoHtml - ? (() => { - let fastAnnotationHooksPromise: Promise | undefined; - - return { - transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { - if (!fastAnnotationHooksPromise) { - fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then( - ({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) => - createOxcComponentNameAnnotateHooks( - options.reactComponentAnnotation?.ignoredComponents || [], - // Vite 8 already loads an oxc-based parser, so reuse it. - buildTool === 'vite' && buildToolMajorVersion === '8' - ? getViteParseAstAsync - : getOxcParseAstAsync, - ), - ); - } - - return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); - }, - }; - })() - : undefined; + const transformFastAnnotations = options.reactComponentAnnotation?.enabled + ? (() => { + let fastAnnotationHooksPromise: Promise | undefined; + + return { + transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { + if (!fastAnnotationHooksPromise) { + fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then( + ({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) => + createOxcComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + // Vite 8 already loads an oxc-based parser, so reuse it. + buildTool === 'vite' && buildToolMajorVersion === '8' ? getViteParseAstAsync : getOxcParseAstAsync, + !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + ), + ); + } + + return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); + }, + }; + })() + : undefined; const transformReplace = Object.keys(replacementValues).length > 0; const shouldTransform = transformAnnotations || transformReplace; diff --git a/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts index 2529460dfa3f..267d8e0ec623 100644 --- a/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts +++ b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts @@ -3,7 +3,7 @@ import { parse } from '@babel/parser'; import MagicString from 'magic-string'; import { describe, expect, it, vi } from 'vitest'; -import componentNameAnnotatePlugin from '../../src/babel-plugin'; +import componentNameAnnotatePlugin, { experimentalComponentNameAnnotatePlugin } from '../../src/babel-plugin'; import { createOxcComponentNameAnnotateHooks, getOxcParseAstAsync, @@ -61,12 +61,18 @@ function collectAnnotations(code: string, id: string): Annotation[] { return annotations; } -async function annotateWithBabel(code: string, id: string, ignoredComponents: string[]): Promise { +async function annotateWithBabel( + code: string, + id: string, + ignoredComponents: string[], + injectIntoHtml = false, +): Promise { + const plugin = injectIntoHtml ? experimentalComponentNameAnnotatePlugin : componentNameAnnotatePlugin; const result = await transformAsync(code, { filename: id, configFile: false, babelrc: false, - plugins: [[componentNameAnnotatePlugin, { ignoredComponents }]], + plugins: [[plugin, { ignoredComponents }]], parserOpts: { sourceType: 'module', allowAwaitOutsideFunction: true, @@ -87,8 +93,9 @@ async function annotateWithOxc( id: string, ignoredComponents: string[] = [], getParseAstAsync: () => Promise = async () => parseAstAsync, + injectIntoHtml = false, ): Promise { - const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync, injectIntoHtml); return hooks.transform(code, id); } @@ -304,6 +311,178 @@ export const List = (props: Props) => { }, ]); }); + + it.each([ + [ + 'HTML roots', + '/src/html-root.jsx', + `export default function App() { + return ( +
+ nested html + +
+ ); +}`, + [], + ], + [ + 'component wrappers', + '/src/wrappers.jsx', + `const Page = () => ( + + +
content
+