Skip to content

Commit e20692b

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 5c677ff commit e20692b

2 files changed

Lines changed: 1231 additions & 0 deletions

File tree

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

Lines changed: 223 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,209 @@ 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,
721+
child,
722+
textComponentNames,
723+
depth - 1,
724+
false
725+
);
726+
if (result === null) {
727+
return null;
728+
}
729+
texts.push(...result);
730+
}
731+
} else if (t.isJSXFragment(child)) {
732+
const result = extractStaticTextFromChildren(t, child, textComponentNames, depth, isRoot);
733+
if (result === null) {
734+
return null;
735+
}
736+
texts.push(...result);
737+
} else if (t.isJSXExpressionContainer(child)) {
738+
if (!t.isJSXEmptyExpression(child.expression)) {
739+
return null;
740+
}
741+
} else if (t.isJSXSpreadChild(child)) {
742+
return null;
743+
}
744+
}
745+
746+
return texts;
747+
}
748+
749+
/**
750+
* Recursively extracts static text from within a recognized text component.
751+
* Handles nested text components (e.g. <Text>Hello <Text style={bold}>world</Text></Text>)
752+
* which is the standard React Native pattern for inline styling.
753+
*
754+
* Returns null when any dynamic content is found, signaling bail-out.
755+
*/
756+
function extractTextFromTextComponent(
757+
t: typeof Babel.types,
758+
node: Babel.types.JSXElement | Babel.types.JSXFragment,
759+
textComponentNames: string[]
760+
): string[] | null {
761+
const texts: string[] = [];
762+
763+
for (const child of node.children) {
764+
if (t.isJSXText(child)) {
765+
const trimmed = child.value.replace(/\s+/g, " ").trim();
766+
if (trimmed) {
767+
texts.push(trimmed);
768+
}
769+
} else if (t.isJSXExpressionContainer(child)) {
770+
if (!t.isJSXEmptyExpression(child.expression)) {
771+
return null;
772+
}
773+
} else if (t.isJSXElement(child)) {
774+
const childName = getElementName(t, child.openingElement);
775+
if (textComponentNames.includes(childName)) {
776+
const innerTexts = extractTextFromTextComponent(t, child, textComponentNames);
777+
if (innerTexts === null) {
778+
return null;
779+
}
780+
texts.push(...innerTexts);
781+
} else {
782+
const innerTexts = extractTextFromTextComponent(t, child, textComponentNames);
783+
if (innerTexts === null) {
784+
return null;
785+
}
786+
}
787+
} else if (t.isJSXFragment(child)) {
788+
const innerTexts = extractTextFromTextComponent(t, child, textComponentNames);
789+
if (innerTexts === null) {
790+
return null;
791+
}
792+
texts.push(...innerTexts);
793+
} else if (t.isJSXSpreadChild(child)) {
794+
return null;
795+
}
796+
}
797+
798+
return texts;
799+
}
800+
801+
function getElementName(
802+
t: typeof Babel.types,
803+
openingElement: Babel.types.JSXOpeningElement
804+
): string {
805+
const name = openingElement.name;
806+
if (t.isJSXIdentifier(name)) {
807+
return name.name;
808+
}
809+
if (t.isJSXMemberExpression(name)) {
810+
return `${getJSXMemberExpressionObjectName(t, name.object)}.${name.property.name}`;
811+
}
812+
return "";
813+
}
814+
815+
/**
816+
* Injects a sentry-label attribute on the root JSX element of a component if
817+
* static text content can be extracted from its children.
818+
*
819+
* When the root is a JSX fragment, the first JSXElement child is used as the
820+
* target for both text extraction and attribute injection (since fragments
821+
* cannot carry attributes).
822+
*/
823+
function maybeInjectSentryLabel(context: JSXProcessingContext, jsxNode: Babel.NodePath): void {
824+
const { t, textComponentNames, ignoredComponents, componentName } = context;
825+
const node = jsxNode.node;
826+
827+
let targetElement: Babel.types.JSXElement;
828+
829+
if (t.isJSXElement(node)) {
830+
targetElement = node;
831+
} else if (t.isJSXFragment(node)) {
832+
const firstChild = node.children.find((c): c is Babel.types.JSXElement => t.isJSXElement(c));
833+
if (!firstChild) {
834+
return;
835+
}
836+
targetElement = firstChild;
837+
} else {
838+
return;
839+
}
840+
841+
const targetElementName = getElementName(t, targetElement.openingElement);
842+
843+
if (
844+
ignoredComponents.some((ignored) => ignored === componentName || ignored === targetElementName)
845+
) {
846+
return;
847+
}
848+
849+
if (
850+
targetElement.openingElement.attributes.some(
851+
(attr) => t.isJSXAttribute(attr) && attr.name.name === SENTRY_LABEL_ATTRIBUTE
852+
)
853+
) {
854+
return;
855+
}
856+
857+
const texts = extractStaticTextFromChildren(
858+
t,
859+
targetElement,
860+
textComponentNames,
861+
MAX_TEXT_SEARCH_DEPTH,
862+
true
863+
);
864+
865+
if (texts === null) {
866+
return;
867+
}
868+
869+
let label = texts.join(" ").replace(/\s+/g, " ").trim();
870+
871+
if (!label) {
872+
return;
873+
}
874+
875+
if (label.length > MAX_LABEL_LENGTH) {
876+
label = label.substring(0, MAX_LABEL_LENGTH - 3) + "...";
877+
}
878+
879+
targetElement.openingElement.attributes.push(
880+
t.jSXAttribute(t.jSXIdentifier(SENTRY_LABEL_ATTRIBUTE), t.stringLiteral(label))
881+
);
882+
}
883+
661884
const UNKNOWN_ELEMENT_NAME = "unknown";

0 commit comments

Comments
 (0)