Skip to content

Commit 7ac1cbc

Browse files
antonisclaude
andauthored
feat(babel): Auto-inject sentry-label from static text children (#925)
* 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> * refactor(babel): Combine autoInjectSentryLabel and textComponentNames into single option Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(babel): Extract text from non-text wrapper elements inside text components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(babel): Guard against null in autoInjectSentryLabel and restore non-text wrapper behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5c677ff commit 7ac1cbc

2 files changed

Lines changed: 1238 additions & 0 deletions

File tree

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

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,21 @@ 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+
53+
interface AutoInjectSentryLabelOpts {
54+
textComponentNames?: string[];
55+
}
56+
4857
interface AnnotationOpts {
4958
native?: boolean;
5059
"annotate-fragments"?: boolean;
5160
ignoredComponents?: string[];
61+
/** @hidden */
62+
autoInjectSentryLabel?: boolean | AutoInjectSentryLabelOpts;
5263
}
5364

5465
interface FragmentContext {
@@ -79,6 +90,10 @@ interface JSXProcessingContext {
7990
ignoredComponents: string[];
8091
/** Fragment context for identifying React fragments */
8192
fragmentContext?: FragmentContext;
93+
/** Whether to auto-inject sentry-label from static text children */
94+
autoInjectSentryLabel: boolean;
95+
/** Component names whose JSXText children are considered text content */
96+
textComponentNames: string[];
8297
}
8398

8499
export { experimentalComponentNameAnnotatePlugin } from "./experimental";
@@ -170,6 +185,11 @@ function createJSXProcessingContext(
170185
attributeNames: attributeNamesFromState(state),
171186
ignoredComponents: state.opts.ignoredComponents ?? [],
172187
fragmentContext: state.sentryFragmentContext,
188+
autoInjectSentryLabel: !!state.opts.autoInjectSentryLabel,
189+
textComponentNames:
190+
(state.opts.autoInjectSentryLabel && typeof state.opts.autoInjectSentryLabel === "object"
191+
? state.opts.autoInjectSentryLabel.textComponentNames
192+
: undefined) ?? DEFAULT_TEXT_COMPONENT_NAMES,
173193
};
174194
}
175195

@@ -261,6 +281,7 @@ function processJSX(
261281

262282
// Use provided componentName or fall back to context componentName
263283
const currentComponentName = componentName ?? context.componentName;
284+
const isRootElement = componentName === undefined;
264285

265286
// NOTE: I don't know of a case where `openingElement` would have more than one item,
266287
// but it's safer to always iterate
@@ -305,6 +326,10 @@ function processJSX(
305326
processJSX(context, child, "");
306327
}
307328
});
329+
330+
if (isRootElement && context.autoInjectSentryLabel) {
331+
maybeInjectSentryLabel(context, jsxNode);
332+
}
308333
}
309334

310335
/**
@@ -658,4 +683,209 @@ function getJSXMemberExpressionObjectName(
658683
return UNKNOWN_ELEMENT_NAME;
659684
}
660685

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

0 commit comments

Comments
 (0)