Skip to content

Commit de26bda

Browse files
committed
fix: Inject component annotations into HTML elements
1 parent cd26b63 commit de26bda

4 files changed

Lines changed: 161 additions & 96 deletions

File tree

packages/babel-plugin-component-annotate/src/constants.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,26 @@ export const KNOWN_INCOMPATIBLE_PLUGINS = [
3030
"@react-navigation",
3131
];
3232

33-
export const DEFAULT_IGNORED_ELEMENTS = [
33+
export const REACT_NATIVE_ELEMENTS = [
34+
"View",
35+
"Text",
36+
"Image",
37+
"ScrollView",
38+
"TextInput",
39+
"TouchableOpacity",
40+
"TouchableHighlight",
41+
"TouchableWithoutFeedback",
42+
"FlatList",
43+
"SectionList",
44+
"Button",
45+
"ActivityIndicator",
46+
"Modal",
47+
"Switch",
48+
"Picker",
49+
"Slider",
50+
];
51+
52+
export const DEFAULT_HTML_ELEMENTS = [
3453
"a",
3554
"abbr",
3655
"address",

packages/babel-plugin-component-annotate/src/index.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,17 @@
3030
* - Added `sentry` to data properties, i.e `data-sentry-component`
3131
* - Converted to TypeScript
3232
* - Code cleanups
33+
* - Inject into root HTML elements instead of React component
3334
*/
3435

3536
import type * as Babel from "@babel/core";
3637
import type { PluginObj, PluginPass } from "@babel/core";
3738

38-
import { DEFAULT_IGNORED_ELEMENTS, KNOWN_INCOMPATIBLE_PLUGINS } from "./constants";
39+
import {
40+
DEFAULT_HTML_ELEMENTS,
41+
KNOWN_INCOMPATIBLE_PLUGINS,
42+
REACT_NATIVE_ELEMENTS,
43+
} from "./constants";
3944

4045
const webComponentName = "data-sentry-component";
4146
const webElementName = "data-sentry-element";
@@ -100,7 +105,7 @@ export default function componentNameAnnotatePlugin({ types: t }: typeof Babel):
100105
}
101106

102107
const context = createJSXProcessingContext(state, t, path.node.id.name);
103-
functionBodyPushAttributes(context, path);
108+
functionBodyPushAttributes(context, path, true);
104109
},
105110
ArrowFunctionExpression(path, state) {
106111
// We're expecting a `VariableDeclarator` like `const MyComponent =`
@@ -121,7 +126,7 @@ export default function componentNameAnnotatePlugin({ types: t }: typeof Babel):
121126
}
122127

123128
const context = createJSXProcessingContext(state, t, parent.id.name);
124-
functionBodyPushAttributes(context, path);
129+
functionBodyPushAttributes(context, path, true);
125130
},
126131
ClassDeclaration(path, state) {
127132
const name = path.get("id");
@@ -178,7 +183,8 @@ function createJSXProcessingContext(
178183
*/
179184
function functionBodyPushAttributes(
180185
context: JSXProcessingContext,
181-
path: Babel.NodePath<Babel.types.Function>
186+
path: Babel.NodePath<Babel.types.Function>,
187+
isRoot: boolean
182188
): void {
183189
let jsxNode: Babel.NodePath;
184190

@@ -220,11 +226,11 @@ function functionBodyPushAttributes(
220226
if (arg.isConditionalExpression()) {
221227
const consequent = arg.get("consequent");
222228
if (consequent.isJSXFragment() || consequent.isJSXElement()) {
223-
processJSX(context, consequent);
229+
processJSX(context, consequent, isRoot);
224230
}
225231
const alternate = arg.get("alternate");
226232
if (alternate.isJSXFragment() || alternate.isJSXElement()) {
227-
processJSX(context, alternate);
233+
processJSX(context, alternate, isRoot);
228234
}
229235
return;
230236
}
@@ -240,7 +246,7 @@ function functionBodyPushAttributes(
240246
return;
241247
}
242248

243-
processJSX(context, jsxNode);
249+
processJSX(context, jsxNode, isRoot);
244250
}
245251

246252
/**
@@ -251,6 +257,7 @@ function functionBodyPushAttributes(
251257
function processJSX(
252258
context: JSXProcessingContext,
253259
jsxNode: Babel.NodePath,
260+
isRoot = true,
254261
componentName?: string
255262
): void {
256263
if (!jsxNode) {
@@ -265,11 +272,15 @@ function processJSX(
265272
const paths = jsxNode.get("openingElement");
266273
const openingElements = Array.isArray(paths) ? paths : [paths];
267274

275+
// Check if this is a fragment - if so, children stay at root; otherwise children are not root
276+
const isChildRoot = jsxNode.isJSXFragment() && isRoot;
277+
268278
openingElements.forEach((openingElement) => {
269279
applyAttributes(
270280
context,
271281
openingElement as Babel.NodePath<Babel.types.JSXOpeningElement>,
272-
currentComponentName
282+
currentComponentName,
283+
isRoot
273284
);
274285
});
275286

@@ -288,7 +299,6 @@ function processJSX(
288299
return;
289300
}
290301

291-
// Children don't receive the data-component attribute so we pass null for componentName unless it's the first child of a Fragment with a node and `annotateFragments` is true
292302
const openingElement = child.get("openingElement");
293303
// TODO: Improve this. We never expect to have multiple opening elements
294304
// but if it's possible, this should work
@@ -298,9 +308,10 @@ function processJSX(
298308

299309
if (shouldSetComponentName && openingElement && openingElement.node) {
300310
shouldSetComponentName = false;
301-
processJSX(context, child, currentComponentName);
311+
processJSX(context, child, isChildRoot, currentComponentName);
302312
} else {
303-
processJSX(context, child, "");
313+
// For fragments, children stay at root level; otherwise not root
314+
processJSX(context, child, isChildRoot, currentComponentName);
304315
}
305316
});
306317
}
@@ -309,11 +320,13 @@ function processJSX(
309320
* Applies Sentry tracking attributes to a JSX opening element.
310321
* Adds component name, element name, and source file attributes while
311322
* respecting ignore lists and fragment detection.
323+
* Only annotates HTML elements, not React components.
312324
*/
313325
function applyAttributes(
314326
context: JSXProcessingContext,
315327
openingElement: Babel.NodePath<Babel.types.JSXOpeningElement>,
316-
componentName: string
328+
componentName: string,
329+
isRoot: boolean
317330
): void {
318331
const { t, attributeNames, ignoredComponents, fragmentContext, sourceFileName } = context;
319332
const [componentAttributeName, elementAttributeName, sourceFileAttributeName] = attributeNames;
@@ -336,10 +349,18 @@ function applyAttributes(
336349
(ignoredComponent) => ignoredComponent === componentName || ignoredComponent === elementName
337350
);
338351

352+
// Check if this is an HTML element (vs a React component)
353+
const isHtml = isHtmlElement(elementName);
354+
355+
// Skip annotation for React components - only annotate HTML elements
356+
if (!isHtml) {
357+
return;
358+
}
359+
339360
// Add a stable attribute for the element name but only for non-DOM names
340361
let isAnIgnoredElement = false;
341362
if (!isAnIgnoredComponent && !hasAttributeWithName(openingElement, elementAttributeName)) {
342-
if (DEFAULT_IGNORED_ELEMENTS.includes(elementName)) {
363+
if (DEFAULT_HTML_ELEMENTS.includes(elementName)) {
343364
isAnIgnoredElement = true;
344365
} else {
345366
// Always add element attribute for non-ignored elements
@@ -351,10 +372,11 @@ function applyAttributes(
351372
}
352373
}
353374

354-
// Add a stable attribute for the component name (absent for non-root elements)
375+
// Add component name to all root-level HTML elements
355376
if (
356377
componentName &&
357378
!isAnIgnoredComponent &&
379+
isRoot &&
358380
!hasAttributeWithName(openingElement, componentAttributeName)
359381
) {
360382
if (componentAttributeName) {
@@ -366,12 +388,12 @@ function applyAttributes(
366388

367389
// Add a stable attribute for the source file name
368390
// Updated condition: add source file for elements that have either:
369-
// 1. A component name (root elements), OR
391+
// 1. At root level, OR
370392
// 2. An element name that's not ignored (child elements)
371393
if (
372394
sourceFileName &&
373395
!isAnIgnoredComponent &&
374-
(componentName || !isAnIgnoredElement) &&
396+
(isRoot || !isAnIgnoredElement) &&
375397
!hasAttributeWithName(openingElement, sourceFileAttributeName)
376398
) {
377399
if (sourceFileAttributeName) {
@@ -593,6 +615,31 @@ function isReactFragment(
593615
return false;
594616
}
595617

618+
/**
619+
* Determines if an element is an HTML element (vs a React component)
620+
* HTML elements start with lowercase letters or are known React Native elements
621+
*/
622+
function isHtmlElement(elementName: string): boolean {
623+
// Unknown elements are not HTML elements
624+
if (elementName === UNKNOWN_ELEMENT_NAME) {
625+
return false;
626+
}
627+
628+
// Check for lowercase first letter (standard HTML elements)
629+
if (elementName.length > 0 && elementName.charAt(0) === elementName.charAt(0).toLowerCase()) {
630+
return true;
631+
}
632+
633+
// React Native elements typically start with uppercase but are still "native" elements
634+
// We consider them HTML-like elements for annotation purposes
635+
if (REACT_NATIVE_ELEMENTS.includes(elementName)) {
636+
return true;
637+
}
638+
639+
// Otherwise, assume it's a React component (PascalCase)
640+
return false;
641+
}
642+
596643
function hasAttributeWithName(
597644
openingElement: Babel.NodePath<Babel.types.JSXOpeningElement>,
598645
name: string | undefined | null

0 commit comments

Comments
 (0)