Skip to content

Commit 2e4382c

Browse files
antonisclaude
andcommitted
feat(babel): Auto-inject sentry-label from static text children
Add opt-in `autoInjectSentryLabel` option to the Babel component annotate plugin. When enabled, the plugin extracts static text from JSX children (up to 3 levels deep) and injects a `sentry-label` attribute on the root element. This gives React Native apps meaningful touch breadcrumb labels without manual annotation. Closes getsentry/sentry-react-native#6098 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f36974 commit 2e4382c

2 files changed

Lines changed: 1154 additions & 0 deletions

File tree

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

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,17 @@ const nativeComponentName = "dataSentryComponent";
4545
const nativeElementName = "dataSentryElement";
4646
const nativeSourceFileName = "dataSentrySourceFile";
4747

48+
const SENTRY_LABEL_ATTRIBUTE = "sentry-label";
49+
const MAX_LABEL_LENGTH = 64;
50+
const DEFAULT_TEXT_COMPONENT_NAMES = ["Text", "text"];
51+
const MAX_TEXT_SEARCH_DEPTH = 3;
52+
4853
interface AnnotationOpts {
4954
native?: boolean;
5055
"annotate-fragments"?: boolean;
5156
ignoredComponents?: string[];
57+
autoInjectSentryLabel?: boolean;
58+
textComponentNames?: string[];
5259
}
5360

5461
interface FragmentContext {
@@ -79,6 +86,10 @@ interface JSXProcessingContext {
7986
ignoredComponents: string[];
8087
/** Fragment context for identifying React fragments */
8188
fragmentContext?: FragmentContext;
89+
/** Whether to auto-inject sentry-label from static text children */
90+
autoInjectSentryLabel: boolean;
91+
/** Component names whose JSXText children are considered text content */
92+
textComponentNames: string[];
8293
}
8394

8495
export { experimentalComponentNameAnnotatePlugin } from "./experimental";
@@ -170,6 +181,8 @@ function createJSXProcessingContext(
170181
attributeNames: attributeNamesFromState(state),
171182
ignoredComponents: state.opts.ignoredComponents ?? [],
172183
fragmentContext: state.sentryFragmentContext,
184+
autoInjectSentryLabel: state.opts.autoInjectSentryLabel === true,
185+
textComponentNames: state.opts.textComponentNames ?? DEFAULT_TEXT_COMPONENT_NAMES,
173186
};
174187
}
175188

@@ -261,6 +274,7 @@ function processJSX(
261274

262275
// Use provided componentName or fall back to context componentName
263276
const currentComponentName = componentName ?? context.componentName;
277+
const isRootElement = componentName === undefined;
264278

265279
// NOTE: I don't know of a case where `openingElement` would have more than one item,
266280
// but it's safer to always iterate
@@ -305,6 +319,10 @@ function processJSX(
305319
processJSX(context, child, "");
306320
}
307321
});
322+
323+
if (isRootElement && context.autoInjectSentryLabel) {
324+
maybeInjectSentryLabel(context, jsxNode);
325+
}
308326
}
309327

310328
/**
@@ -658,4 +676,200 @@ function getJSXMemberExpressionObjectName(
658676
return UNKNOWN_ELEMENT_NAME;
659677
}
660678

679+
/**
680+
* Extracts static text content from JSX children, searching up to a depth limit.
681+
* Collects text from JSXText nodes of the root element and from recognized
682+
* text components (e.g. <Text>). Non-text custom components are traversed
683+
* but their own JSXText is not collected.
684+
*
685+
* Returns null when dynamic content is found anywhere in the subtree,
686+
* signaling that the entire label should be skipped.
687+
*/
688+
function extractStaticTextFromChildren(
689+
t: typeof Babel.types,
690+
node: Babel.types.JSXElement | Babel.types.JSXFragment,
691+
textComponentNames: string[],
692+
depth: number,
693+
isRoot: boolean
694+
): string[] | null {
695+
if (depth <= 0) {
696+
return [];
697+
}
698+
699+
const texts: string[] = [];
700+
701+
for (const child of node.children) {
702+
if (t.isJSXText(child)) {
703+
if (isRoot) {
704+
const trimmed = child.value.replace(/\s+/g, " ").trim();
705+
if (trimmed) {
706+
texts.push(trimmed);
707+
}
708+
}
709+
} else if (t.isJSXElement(child)) {
710+
const childName = getElementName(t, child.openingElement);
711+
712+
if (textComponentNames.includes(childName)) {
713+
const innerTexts = extractTextFromTextComponent(t, child, textComponentNames);
714+
if (innerTexts === null) {
715+
return null;
716+
}
717+
texts.push(...innerTexts);
718+
} else {
719+
const result = extractStaticTextFromChildren(
720+
t, child, textComponentNames, depth - 1, false
721+
);
722+
if (result === null) {
723+
return null;
724+
}
725+
texts.push(...result);
726+
}
727+
} else if (t.isJSXFragment(child)) {
728+
const result = extractStaticTextFromChildren(
729+
t, child, textComponentNames, depth - 1, false
730+
);
731+
if (result === null) {
732+
return null;
733+
}
734+
texts.push(...result);
735+
} else if (t.isJSXExpressionContainer(child)) {
736+
if (!t.isJSXEmptyExpression(child.expression)) {
737+
return null;
738+
}
739+
} else if (t.isJSXSpreadChild(child)) {
740+
return null;
741+
}
742+
}
743+
744+
return texts;
745+
}
746+
747+
/**
748+
* Recursively extracts static text from within a recognized text component.
749+
* Handles nested text components (e.g. <Text>Hello <Text style={bold}>world</Text></Text>)
750+
* which is the standard React Native pattern for inline styling.
751+
*
752+
* Returns null when any dynamic content is found, signaling bail-out.
753+
*/
754+
function extractTextFromTextComponent(
755+
t: typeof Babel.types,
756+
node: Babel.types.JSXElement,
757+
textComponentNames: string[]
758+
): string[] | null {
759+
const texts: string[] = [];
760+
761+
for (const child of node.children) {
762+
if (t.isJSXText(child)) {
763+
const trimmed = child.value.replace(/\s+/g, " ").trim();
764+
if (trimmed) {
765+
texts.push(trimmed);
766+
}
767+
} else if (t.isJSXExpressionContainer(child)) {
768+
if (!t.isJSXEmptyExpression(child.expression)) {
769+
return null;
770+
}
771+
} else if (t.isJSXElement(child)) {
772+
const childName = getElementName(t, child.openingElement);
773+
if (textComponentNames.includes(childName)) {
774+
const innerTexts = extractTextFromTextComponent(t, child, textComponentNames);
775+
if (innerTexts === null) {
776+
return null;
777+
}
778+
texts.push(...innerTexts);
779+
}
780+
} else if (t.isJSXSpreadChild(child)) {
781+
return null;
782+
}
783+
}
784+
785+
return texts;
786+
}
787+
788+
function getElementName(
789+
t: typeof Babel.types,
790+
openingElement: Babel.types.JSXOpeningElement
791+
): string {
792+
const name = openingElement.name;
793+
if (t.isJSXIdentifier(name)) {
794+
return name.name;
795+
}
796+
if (t.isJSXMemberExpression(name)) {
797+
return `${getJSXMemberExpressionObjectName(t, name.object)}.${name.property.name}`;
798+
}
799+
return "";
800+
}
801+
802+
/**
803+
* Injects a sentry-label attribute on the root JSX element of a component if
804+
* static text content can be extracted from its children.
805+
*
806+
* When the root is a JSX fragment, the first JSXElement child is used as the
807+
* target for both text extraction and attribute injection (since fragments
808+
* cannot carry attributes).
809+
*/
810+
function maybeInjectSentryLabel(
811+
context: JSXProcessingContext,
812+
jsxNode: Babel.NodePath
813+
): void {
814+
const { t, textComponentNames, ignoredComponents, componentName } = context;
815+
const node = jsxNode.node;
816+
817+
let targetElement: Babel.types.JSXElement;
818+
819+
if (t.isJSXElement(node)) {
820+
targetElement = node;
821+
} else if (t.isJSXFragment(node)) {
822+
const firstChild = node.children.find((c): c is Babel.types.JSXElement => t.isJSXElement(c));
823+
if (!firstChild) {
824+
return;
825+
}
826+
targetElement = firstChild;
827+
} else {
828+
return;
829+
}
830+
831+
const targetElementName = getElementName(t, targetElement.openingElement);
832+
833+
if (
834+
ignoredComponents.some(
835+
(ignored) => ignored === componentName || ignored === targetElementName
836+
)
837+
) {
838+
return;
839+
}
840+
841+
if (
842+
targetElement.openingElement.attributes.some(
843+
(attr) => t.isJSXAttribute(attr) && attr.name.name === SENTRY_LABEL_ATTRIBUTE
844+
)
845+
) {
846+
return;
847+
}
848+
849+
const texts = extractStaticTextFromChildren(
850+
t, targetElement, textComponentNames, MAX_TEXT_SEARCH_DEPTH, true
851+
);
852+
853+
if (texts === null) {
854+
return;
855+
}
856+
857+
let label = texts.join(" ").replace(/\s+/g, " ").trim();
858+
859+
if (!label) {
860+
return;
861+
}
862+
863+
if (label.length > MAX_LABEL_LENGTH) {
864+
label = label.substring(0, MAX_LABEL_LENGTH - 3) + "...";
865+
}
866+
867+
targetElement.openingElement.attributes.push(
868+
t.jSXAttribute(
869+
t.jSXIdentifier(SENTRY_LABEL_ATTRIBUTE),
870+
t.stringLiteral(label)
871+
)
872+
);
873+
}
874+
661875
const UNKNOWN_ELEMENT_NAME = "unknown";

0 commit comments

Comments
 (0)