Skip to content

Commit 7070bd6

Browse files
authored
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
1 parent ea27349 commit 7070bd6

7 files changed

Lines changed: 350 additions & 53 deletions

File tree

‎dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => {
3434
"data-sentry-component": "App",
3535
children: [/* @__PURE__ */ jsxDEV(ComponentA, {}, void 0, false, {
3636
fileName: _jsxFileName,
37-
lineNumber: 4,
37+
lineNumber: 6,
3838
columnNumber: 7
3939
}, this), ";"]
4040
}, void 0, true, {
4141
fileName: _jsxFileName,
42-
lineNumber: 3,
43-
columnNumber: 10
42+
lineNumber: 5,
43+
columnNumber: 5
4444
}, this);
4545
}
4646
console.log(App());

‎packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts‎

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ import type {
1616
import { isAstNode, isObjectLike } from './component-annotation-oxc-ast';
1717

1818
const UNKNOWN_ELEMENT_NAME = 'unknown';
19+
const REACT_NATIVE_ELEMENTS = new Set([
20+
'Image',
21+
'Text',
22+
'View',
23+
'ScrollView',
24+
'TextInput',
25+
'TouchableOpacity',
26+
'TouchableHighlight',
27+
'TouchableWithoutFeedback',
28+
'FlatList',
29+
'SectionList',
30+
'ActivityIndicator',
31+
'Button',
32+
'Switch',
33+
'Modal',
34+
'SafeAreaView',
35+
'StatusBar',
36+
'KeyboardAvoidingView',
37+
'RefreshControl',
38+
'Picker',
39+
'Slider',
40+
]);
1941
const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const;
2042
const WEB_ATTRIBUTE_NAME_SET = new Set<string>(WEB_ATTRIBUTE_NAMES);
2143

@@ -140,20 +162,79 @@ export function addPendingAttributes(
140162
return;
141163
}
142164

143-
const insertion =
144-
pendingInsertion ??
145-
insertionsByOffset
146-
.set(offset, {
147-
offset,
148-
attributeValues: new Map(),
149-
})
150-
.get(offset);
165+
const insertion = getOrCreateInsertion(insertionsByOffset, offset);
151166

152167
for (const [name, value] of attributes) {
153-
insertion?.attributeValues.set(name, value);
168+
insertion.attributeValues.set(name, value);
154169
}
155170
}
156171

172+
/**
173+
* Returns `true` when the element is an HTML element, because HTML mode only
174+
* annotates the first HTML elements below a component root.
175+
*/
176+
export function addPendingHtmlAttribute(
177+
code: string,
178+
openingElement: JSXOpeningElementNode,
179+
componentName: string,
180+
ignoredComponents: string[],
181+
fragmentContext: FragmentContext,
182+
insertionsByOffset: Map<number, PendingInsertion>,
183+
): boolean {
184+
if (isReactFragment(openingElement, fragmentContext)) {
185+
return false;
186+
}
187+
188+
const elementName = getJSXName(openingElement.name);
189+
190+
if (!isHtmlElement(elementName)) {
191+
return false;
192+
}
193+
194+
if (ignoredComponents.includes(componentName) || ignoredComponents.includes(elementName)) {
195+
return true;
196+
}
197+
198+
const offset = getInsertionOffset(code, openingElement);
199+
if (offset === null) {
200+
return true;
201+
}
202+
203+
if (
204+
getExistingAttributeNames(openingElement).has(WEB_COMPONENT_NAME) ||
205+
insertionsByOffset.get(offset)?.attributeValues.has(WEB_COMPONENT_NAME)
206+
) {
207+
return true;
208+
}
209+
210+
getOrCreateInsertion(insertionsByOffset, offset).attributeValues.set(WEB_COMPONENT_NAME, componentName);
211+
212+
return true;
213+
}
214+
215+
function isHtmlElement(elementName: string): boolean {
216+
if (elementName === UNKNOWN_ELEMENT_NAME) {
217+
return false;
218+
}
219+
220+
if (elementName.charAt(0) === elementName.charAt(0).toLowerCase()) {
221+
return true;
222+
}
223+
224+
return REACT_NATIVE_ELEMENTS.has(elementName);
225+
}
226+
227+
function getOrCreateInsertion(insertionsByOffset: Map<number, PendingInsertion>, offset: number): PendingInsertion {
228+
let insertion = insertionsByOffset.get(offset);
229+
230+
if (!insertion) {
231+
insertion = { offset, attributeValues: new Map() };
232+
insertionsByOffset.set(offset, insertion);
233+
}
234+
235+
return insertion;
236+
}
237+
157238
export function toAttributeInsertions(insertionsByOffset: Map<number, PendingInsertion>): AttributeInsertion[] {
158239
return [...insertionsByOffset.values()].map(({ offset, attributeValues }) => ({
159240
offset,
@@ -181,7 +262,8 @@ function getOrderedAttributes(attributeValues: ReadonlyMap<string, string>): Com
181262

182263
for (const name of WEB_ATTRIBUTE_NAMES) {
183264
const value = attributeValues.get(name);
184-
if (value) {
265+
// HTML mode writes an empty component name for anonymous classes.
266+
if (value !== undefined) {
185267
attributes.push([name, value]);
186268
}
187269
}

‎packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-oxc-ast';
22
import {
33
addPendingAttributes,
4+
addPendingHtmlAttribute,
45
getStringName,
56
isJSXElement,
67
isJSXRoot,
@@ -16,20 +17,54 @@ export function collectOxcComponentAnnotationInsertions(
1617
ast: AstNode,
1718
ignoredComponents: string[],
1819
sourceFileName: string,
20+
injectIntoHtml: boolean,
1921
): AttributeInsertion[] {
2022
const fragmentContext = collectFragmentContext(ast);
2123
const components = collectComponentJSXRoots(ast);
2224
const insertionsByOffset = new Map<number, { offset: number; attributeValues: Map<string, string> }>();
2325

2426
for (const component of components) {
2527
for (const root of component.roots) {
26-
processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset);
28+
if (injectIntoHtml) {
29+
processHtmlJSX(code, root, component.name, ignoredComponents, fragmentContext, insertionsByOffset);
30+
} else {
31+
processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset);
32+
}
2733
}
2834
}
2935

3036
return toAttributeInsertions(insertionsByOffset);
3137
}
3238

39+
function processHtmlJSX(
40+
code: string,
41+
node: JSXRootNode,
42+
componentName: string,
43+
ignoredComponents: string[],
44+
fragmentContext: FragmentContext,
45+
insertionsByOffset: Map<number, { offset: number; attributeValues: Map<string, string> }>,
46+
): void {
47+
if (
48+
isJSXElement(node) &&
49+
addPendingHtmlAttribute(
50+
code,
51+
node.openingElement,
52+
componentName,
53+
ignoredComponents,
54+
fragmentContext,
55+
insertionsByOffset,
56+
)
57+
) {
58+
return;
59+
}
60+
61+
for (const child of node.children ?? []) {
62+
if (isJSXRoot(child)) {
63+
processHtmlJSX(code, child, componentName, ignoredComponents, fragmentContext, insertionsByOffset);
64+
}
65+
}
66+
}
67+
3368
function getJSXRootsFromReturnArgument(argument: unknown): JSXRootNode[] {
3469
if (isJSXRoot(argument)) {
3570
return [argument];

‎packages/bundler-plugins/src/core/component-annotation-oxc.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ async function annotateWithOxcParser(
101101
id: string,
102102
ignoredComponents: string[],
103103
parseAstAsync: ParseAstAsync,
104+
injectIntoHtml: boolean,
104105
meta?: ComponentAnnotationTransformMeta,
105106
): Promise<ComponentAnnotationTransformResult> {
106107
const idWithoutQueryAndHash = stripQueryAndHashFromPath(id);
@@ -129,6 +130,7 @@ async function annotateWithOxcParser(
129130
ast,
130131
ignoredComponents,
131132
path.basename(idWithoutQueryAndHash),
133+
injectIntoHtml,
132134
);
133135

134136
if (insertions.length === 0) {
@@ -159,6 +161,7 @@ async function annotateWithOxcParser(
159161
export function createOxcComponentNameAnnotateHooks(
160162
ignoredComponents: string[],
161163
getParseAstAsync: () => Promise<ParseAstAsync | null>,
164+
injectIntoHtml = false,
162165
): {
163166
transform(
164167
code: string,
@@ -175,7 +178,7 @@ export function createOxcComponentNameAnnotateHooks(
175178
return undefined;
176179
}
177180

178-
return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, meta);
181+
return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, injectIntoHtml, meta);
179182
} catch {
180183
return undefined;
181184
}

‎packages/bundler-plugins/src/rollup/index.ts‎

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -168,31 +168,29 @@ export function _rollupPluginInternal(
168168
!!options.reactComponentAnnotation?._experimentalInjectIntoHtml,
169169
)
170170
: undefined;
171-
const transformFastAnnotations =
172-
options.reactComponentAnnotation?.enabled && !options.reactComponentAnnotation?._experimentalInjectIntoHtml
173-
? (() => {
174-
let fastAnnotationHooksPromise: Promise<FastAnnotationHooks> | undefined;
175-
176-
return {
177-
transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) {
178-
if (!fastAnnotationHooksPromise) {
179-
fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then(
180-
({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) =>
181-
createOxcComponentNameAnnotateHooks(
182-
options.reactComponentAnnotation?.ignoredComponents || [],
183-
// Vite 8 already loads an oxc-based parser, so reuse it.
184-
buildTool === 'vite' && buildToolMajorVersion === '8'
185-
? getViteParseAstAsync
186-
: getOxcParseAstAsync,
187-
),
188-
);
189-
}
190-
191-
return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta));
192-
},
193-
};
194-
})()
195-
: undefined;
171+
const transformFastAnnotations = options.reactComponentAnnotation?.enabled
172+
? (() => {
173+
let fastAnnotationHooksPromise: Promise<FastAnnotationHooks> | undefined;
174+
175+
return {
176+
transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) {
177+
if (!fastAnnotationHooksPromise) {
178+
fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then(
179+
({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) =>
180+
createOxcComponentNameAnnotateHooks(
181+
options.reactComponentAnnotation?.ignoredComponents || [],
182+
// Vite 8 already loads an oxc-based parser, so reuse it.
183+
buildTool === 'vite' && buildToolMajorVersion === '8' ? getViteParseAstAsync : getOxcParseAstAsync,
184+
!!options.reactComponentAnnotation?._experimentalInjectIntoHtml,
185+
),
186+
);
187+
}
188+
189+
return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta));
190+
},
191+
};
192+
})()
193+
: undefined;
196194

197195
const transformReplace = Object.keys(replacementValues).length > 0;
198196
const shouldTransform = transformAnnotations || transformReplace;

0 commit comments

Comments
 (0)