From fc4890506f795d701b59ccc15f638fd22849c17b Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Fri, 29 Aug 2025 15:23:25 +0100 Subject: [PATCH 1/6] Prevent node_modules files from being affected the babel plugin --- packages/react-native-babel-plugin/src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 375d5e889..a2193f3d8 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -35,6 +35,10 @@ export default declare( const pluginState: PluginPassState = state; const { path: p, name } = getFileInfo(this); + if (p?.includes('node_modules')) { + return; + } + pluginState.fileInfo = { path: p, name }; insertSetupFlag(path, api.types); @@ -54,6 +58,12 @@ export default declare( const pluginState: PluginPassState = state; const name = getNodeName(t, path.node.openingElement); + const { path: p } = getFileInfo(this); + + if (p?.includes('node_modules')) { + return; + } + if (!name) { return; } From 71f182c1dfbdfc42514ac6bf05dc2fc8dd2e328e Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Fri, 29 Aug 2025 15:31:21 +0100 Subject: [PATCH 2/6] Ensure babel plugin is initialized once per platform The goal is to prevent caching issue in metro bundler, that would occur when running both android and iOS apps on the same metro bundler. --- .../src/actions/global/index.ts | 7 +++-- .../react-native-babel-plugin/src/index.ts | 2 +- .../src/utils/PluginState.ts | 29 +++++++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/react-native-babel-plugin/src/actions/global/index.ts b/packages/react-native-babel-plugin/src/actions/global/index.ts index 148434be1..2eb39a55b 100644 --- a/packages/react-native-babel-plugin/src/actions/global/index.ts +++ b/packages/react-native-babel-plugin/src/actions/global/index.ts @@ -17,16 +17,17 @@ import { export function insertSetupFlag( path: Babel.NodePath, + state: PluginPassState, t: BabelTypes ) { - const pluginState = PluginState.getInstance(); + const pluginState = PluginState.getInstance(state); // Only set the flag on the entry file of the project - if (pluginState.isInitialized) { + if (pluginState.isInitialized()) { return; } - pluginState.isInitialized = true; + pluginState.initialize(); const flagNode = getAssignmentNode( t, diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index a2193f3d8..c82a5bfc0 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -41,8 +41,8 @@ export default declare( pluginState.fileInfo = { path: p, name }; - insertSetupFlag(path, api.types); loadImportMap(path, api.types, pluginState); + insertSetupFlag(path, state, api.types); }, exit(path, state) { const pluginState: PluginPassState = state; diff --git a/packages/react-native-babel-plugin/src/utils/PluginState.ts b/packages/react-native-babel-plugin/src/utils/PluginState.ts index 189cfc8d2..c206fa7ce 100644 --- a/packages/react-native-babel-plugin/src/utils/PluginState.ts +++ b/packages/react-native-babel-plugin/src/utils/PluginState.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { PluginPassState } from '../types'; + const PluginStateErrors = { ALREADY_INITIALIZED: 'Plugin State already initialized, please use `getInstance`.' @@ -12,7 +14,9 @@ const PluginStateErrors = { export class PluginState { static instance: PluginState | null = null; - isInitialized: boolean = false; + private pluginInitialized: Record = {}; + + private state: PluginPassState | null = null; private constructor() { if (PluginState.instance) { @@ -21,11 +25,32 @@ export class PluginState { PluginState.instance = this; } - static getInstance() { + static getInstance(state?: PluginPassState) { if (!PluginState.instance) { PluginState.instance = new PluginState(); } + if (state) { + PluginState.instance['state'] = state; + } + return PluginState.instance; } + + private getPlatform() { + return ( + // @ts-ignore + (this.state?.file?.opts?.caller?.platform as string) || 'unknown' + ); + } + + initialize() { + const platform = this.getPlatform(); + this.pluginInitialized[platform] = true; + } + + isInitialized() { + const platform = this.getPlatform(); + return this.pluginInitialized[platform] || false; + } } From ea3fb091353cad06be3c75602e073f790ceae642 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Fri, 29 Aug 2025 15:37:15 +0100 Subject: [PATCH 3/6] Update babel plugin types & constants to accommodate the new RUM Action Tracking config options --- .../src/constants/global.ts | 8 +++++++ .../src/constants/rum.ts | 3 ++- .../src/types/general.ts | 23 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/react-native-babel-plugin/src/constants/global.ts b/packages/react-native-babel-plugin/src/constants/global.ts index 777a7c53c..edbefe531 100644 --- a/packages/react-native-babel-plugin/src/constants/global.ts +++ b/packages/react-native-babel-plugin/src/constants/global.ts @@ -7,3 +7,11 @@ export const PluginConstants = { PLUGIN_ENABLED: '__DD_RN_BABEL_PLUGIN_ENABLED__' } as const; + +export const defaultPluginOptions = { + components: { + useContent: true, + useNamePrefix: true, + tracked: [] + } +}; diff --git a/packages/react-native-babel-plugin/src/constants/rum.ts b/packages/react-native-babel-plugin/src/constants/rum.ts index c29d32ffe..7b1e4b680 100644 --- a/packages/react-native-babel-plugin/src/constants/rum.ts +++ b/packages/react-native-babel-plugin/src/constants/rum.ts @@ -8,7 +8,8 @@ export const RumActionConstants = { ACTION_CLASS: 'DdBabelInteractionTracking', ACTION_CLASS_INSTANCE: 'getInstance', ACTION_FUNCTION_WRAPPER: 'wrapRumAction', - IMPORT_PACKAGE: '@datadog/mobile-react-native' + IMPORT_PACKAGE: '@datadog/mobile-react-native', + UTILS_FUNCTION_EXTRACT_TEXT: '__ddExtractText' } as const; export const RumAction = { diff --git a/packages/react-native-babel-plugin/src/types/general.ts b/packages/react-native-babel-plugin/src/types/general.ts index 281af2f25..b94c9021b 100644 --- a/packages/react-native-babel-plugin/src/types/general.ts +++ b/packages/react-native-babel-plugin/src/types/general.ts @@ -6,6 +6,8 @@ import type * as Babel from '@babel/core'; +import type { RumAction } from '../constants'; + export const MemoTypes = { USE_CALLBACK: 'useCallback', USE_MEMO: 'useMemo' @@ -15,15 +17,32 @@ export type MemoType = typeof MemoTypes[keyof typeof MemoTypes]; export type PluginAPI = typeof Babel & Babel.ConfigAPI; +export type TrackedComponent = { + name: string; + useContent?: boolean; + useNamePrefix?: boolean; + contentProp?: string; + handlers: { + event: string; + action: keyof typeof RumAction; + mode?: 'default' | 'delayed'; + }[]; +}; + export type PluginOptions = { - actionNameAttribute: string; + actionNameAttribute?: string; + components: { + useContent: boolean; + useNamePrefix: boolean; + tracked: TrackedComponent[]; + }; }; export type PluginPassState = Babel.PluginPass & { fileInfo?: { path: string | null; name: string | null }; - tapMappings?: Record; memoization?: Record; hasValidTapAction?: boolean; + trackedComponents?: Record>; }; export type PluginResult = Babel.PluginObj; From d16577002bfc9eaa378e9ff673fedb4b43e862a0 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Wed, 3 Sep 2025 11:42:10 +0100 Subject: [PATCH 4/6] Add Babel config options for RUM Action Tracking & support content extraction - Allow customization through Babel options to extend RUM Action Tracking to custom/third-party components - Allow for retrieval of components' content through children or props --- packages/core/src/index.tsx | 4 +- .../DdBabelInteractionTracking.ts | 58 +++- .../interactionTracking/ddBabelUtils.ts | 146 +++++++++ .../src/actions/global/index.ts | 30 +- .../src/actions/rum/index.ts | 303 +++++++++++++++++- .../src/actions/rum/tap.ts | 206 ++++++++---- .../react-native-babel-plugin/src/index.ts | 39 ++- .../src/utils/PluginState.ts | 4 +- .../src/utils/nodeProcessing.ts | 128 +++++++- 9 files changed, 810 insertions(+), 108 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/interactionTracking/ddBabelUtils.ts diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 56faeff2a..9332354dc 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -24,6 +24,7 @@ import { TrackingConsent } from './TrackingConsent'; import { DdLogs } from './logs/DdLogs'; import { DdRum } from './rum/DdRum'; import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking'; +import { __ddExtractText } from './rum/instrumentation/interactionTracking/ddBabelUtils'; import { DatadogTracingContext } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingContext'; import { DatadogTracingIdentifier } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier'; import { @@ -77,7 +78,8 @@ export { TracingIdFormat, DatadogTracingIdentifier, DatadogTracingContext, - DdBabelInteractionTracking + DdBabelInteractionTracking, + __ddExtractText }; export type { Timestamp, diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts index bdf4c35ae..8d36cb806 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts @@ -26,10 +26,13 @@ type BabelConfig = { }; type TargetObject = { - compoenentName: string; - 'dd-action-name': string; - accessibilityLabel: string; - [key: string]: string; + getContent: (() => string[]) | undefined; + options: { useContent: boolean; useNamePrefix: boolean }; + handlerArgs: any[]; + componentName: string; + 'dd-action-name': string[]; + accessibilityLabel: string[]; + [key: string]: any; }; export class DdBabelInteractionTracking { @@ -64,6 +67,9 @@ export class DdBabelInteractionTracking { private getTargetName(targetObject: TargetObject) { const { + getContent, + options, + handlerArgs, componentName, 'dd-action-name': actionName, accessibilityLabel, @@ -72,20 +78,44 @@ export class DdBabelInteractionTracking { const { useAccessibilityLabel } = DdBabelInteractionTracking.config; - if (actionName) { - return actionName; - } + const tryContent = () => { + const content = getContent?.(); + if (content && content.length > 0) { + return content; + } - const keys = Object.keys(attrs); - if (keys.length) { - return attrs[keys[0]]; - } + return null; + }; - if (useAccessibilityLabel && accessibilityLabel) { - return accessibilityLabel; + const getAccessibilityLabel = () => + useAccessibilityLabel && accessibilityLabel + ? accessibilityLabel + : null; + + const index = handlerArgs + ? handlerArgs.find(x => typeof x === 'number') || 0 + : 0; + + // Order: content → actionName → actionNameAttribute → accessibilityLabel + const selectedContent = + tryContent() || + actionName || + Object.values(attrs)[0] || + getAccessibilityLabel(); + + if (!selectedContent) { + return componentName; } - return componentName; + // Fail-safe in case the our 'index' value turns out to not be a real index + const output = + index + 1 > selectedContent.length || index < 0 + ? selectedContent[0] + : selectedContent[index]; + + return options.useNamePrefix + ? `${componentName} ("${output}")` + : output; } wrapRumAction( diff --git a/packages/core/src/rum/instrumentation/interactionTracking/ddBabelUtils.ts b/packages/core/src/rum/instrumentation/interactionTracking/ddBabelUtils.ts new file mode 100644 index 000000000..caf0e2a5f --- /dev/null +++ b/packages/core/src/rum/instrumentation/interactionTracking/ddBabelUtils.ts @@ -0,0 +1,146 @@ +import * as React from 'react'; + +type ExtractChild = + | string + | number + | boolean + | React.ReactElement + | Iterable + | React.ReactPortal; + +const LABEL_PROPS = ['children', 'label', 'title', 'text']; + +const normalize = (s: string) => s.replace(/\s+/g, ' ').trim(); + +/** + * Extracts readable text from arbitrary values commonly found in React trees. + * + * @param node - Any value: primitives, arrays, iterables, functions, or React elements. + * @param prefer - Optional list of preferred values (e.g., title/label) to attempt first. + * @returns Array of strings. + */ +export function __ddExtractText(node: any, prefer?: any[]): string[] { + // If caller provided preferred values (title/label/etc.), use those first. + if (Array.isArray(prefer)) { + const preferred = prefer + .flatMap(v => __ddExtractText(v)) // recurse so expressions/arrays work + .map(normalize) + .filter(Boolean); + + if (preferred.length) { + return preferred; + } + } + + // Base cases + if (node == null || typeof node === 'boolean') { + return []; + } + + if (typeof node === 'string' || typeof node === 'number') { + return [normalize(String(node))]; + } + + // Arrays / iterables → flatten results (don’t concatenate yet) + if (Array.isArray(node)) { + return node + .flatMap(x => __ddExtractText(x)) + .map(normalize) + .filter(Boolean); + } + + if (typeof node === 'object' && Symbol.iterator in node) { + return Array.from(node as Iterable) + .flatMap(x => __ddExtractText(x)) + .map(normalize) + .filter(Boolean); + } + + // Zero-arg render prop + if (typeof node === 'function' && node.length === 0) { + try { + return __ddExtractText(node()); + } catch { + return []; + } + } + + // React elements + if (React.isValidElement(node)) { + const props: any = (node as any).props ?? {}; + + // If the element itself has a direct label-ish prop, prefer it. + for (const propKey of LABEL_PROPS) { + if (propKey === 'children') { + continue; // handle children below + } + + const propValue = props[propKey]; + if (propValue != null) { + const got = __ddExtractText(propValue) + .map(normalize) + .filter(Boolean); + + if (got.length) { + return got; + } + } + } + + // Inspect children. Decide whether to return ONE joined label or MANY. + const rawChildData = (Array.isArray(props.children) + ? props.children + : [props.children]) as ExtractChild[]; + + const children = rawChildData.filter(c => c != null && c !== false); + + if (children.length === 0) { + return []; + } + + // Extract each child to a list of strings (not joined) + const perChild = children.map(child => __ddExtractText(child)); + + // Heuristic: treat as *compound* if multiple children look like “items” + // e.g., at least two direct children have a label-ish prop or yield non-empty text individually. + let labeledChildCount = 0; + children.forEach((child, i) => { + let hasLabelProp = false; + + if (React.isValidElement(child)) { + const childProps: any = (child as any).props ?? {}; + hasLabelProp = LABEL_PROPS.some(k => childProps?.[k] != null); + } + + const childTextCount = perChild[i].filter(Boolean).length; + if (hasLabelProp || childTextCount > 0) { + labeledChildCount++; + } + }); + + const flat = perChild.flat().map(normalize).filter(Boolean); + + // If there are multiple *direct* labelled children, return many (compound). + // Otherwise, return a single joined label. + if (labeledChildCount > 1) { + // De-duplicate while preserving order + const seen = new Set(); + const out: string[] = []; + + for (const str of flat) { + const key = str; + if (!seen.has(key)) { + seen.add(key); + out.push(str); + } + } + return out; + } + + // Not “compound”: join everything into one readable string + const joined = normalize(flat.join(' ')); + return joined ? [joined] : []; + } + + return []; +} diff --git a/packages/react-native-babel-plugin/src/actions/global/index.ts b/packages/react-native-babel-plugin/src/actions/global/index.ts index 2eb39a55b..7d553a8f6 100644 --- a/packages/react-native-babel-plugin/src/actions/global/index.ts +++ b/packages/react-native-babel-plugin/src/actions/global/index.ts @@ -6,8 +6,8 @@ import type * as Babel from '@babel/core'; -import { PluginConstants, tapElementsMap } from '../../constants'; -import type { BabelTypes, PluginPassState } from '../../types'; +import { RumAction, PluginConstants, tapElementsMap } from '../../constants'; +import type { BabelTypes, PluginPassState, PluginOptions } from '../../types'; import { PluginState, getAssignmentNode, @@ -42,7 +42,8 @@ export function insertSetupFlag( export function loadImportMap( path: Babel.NodePath, t: BabelTypes, - pluginState: PluginPassState + pluginState: PluginPassState, + options: PluginOptions ) { path.traverse({ ImportDeclaration(p) { @@ -53,7 +54,9 @@ export function loadImportMap( return; } - const tapElementsImportMap: Record = {}; + if (!pluginState.trackedComponents) { + pluginState.trackedComponents = {}; + } for (const specifier of specifiers) { if (!t.isImportSpecifier(specifier)) { @@ -61,20 +64,23 @@ export function loadImportMap( } const importName = getNodeName(t, specifier.imported); + const localName = getNodeName(t, specifier.local); + const elementEvents = importName ? tapElementsMap[importName] : null; - if (elementEvents) { - const importLocalName = getNodeName(t, specifier.local); - - if (importLocalName) { - tapElementsImportMap[importLocalName] = elementEvents; - } + if (elementEvents && localName) { + pluginState.trackedComponents[localName] = { + useContent: options.components.useContent, + useNamePrefix: options.components.useNamePrefix, + handlers: elementEvents.map(event => ({ + event, + action: RumAction.TAP // TODO: RUM-11584 change once we support more actions + })) + }; } } - - pluginState.tapMappings = tapElementsImportMap; } }); } diff --git a/packages/react-native-babel-plugin/src/actions/rum/index.ts b/packages/react-native-babel-plugin/src/actions/rum/index.ts index a81d0d2dd..f5ec2c700 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/index.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/index.ts @@ -26,23 +26,48 @@ import type { import { getImportDeclaration, getNodeName, - insertAtProgramTop + insertAtProgramTop, + toExpression } from '../../utils'; import { handleTapAction } from './tap'; +/** + * Inserts RUM Action Tracking import at the top of the Program. + * + * Adds a single import declaration for: + * - the action tracking class (e.g., `DdBabelInteractionTracking`) + * - the text extraction helper (`__ddExtractText`) + * + * @param t Babel types helper. + * @param path Program path to mutate. + */ export function insertRumActionImport( t: typeof Babel.types, path: Babel.NodePath ) { + // Build the import declaration for the runtime + helper const importNode = getImportDeclaration( t, - RumActionConstants.ACTION_CLASS, + [ + RumActionConstants.ACTION_CLASS, + RumActionConstants.UTILS_FUNCTION_EXTRACT_TEXT + ], RumActionConstants.IMPORT_PACKAGE ); insertAtProgramTop(path, importNode); } +/** + * Main entry point to wrap a JSX element's relevant attributes (handlers + DD props) + * with RUM action tracking. + * + * @param componentName The host component name (e.g., "GestureButton"). + * @param t Babel types helper. + * @param path JSXElement path to process. + * @param state Plugin state containing `trackedComponents` and config. + * @param options Plugin options (e.g., custom action name attribute). + */ export function handleJSXElementActionPaths( componentName: string, t: typeof Babel.types, @@ -50,12 +75,24 @@ export function handleJSXElementActionPaths( state: PluginPassState, options: PluginOptions ) { + // Avoid double-processing the same element + if (path.node?.extra?.__wrappedForRum) { + return; + } + + // Gather targets and construct ddValues/options const { actionPathList, actionPathNames, ddValues } = getJSXElementActionPaths(componentName, t, path, state, options); + // Create known custom components list tracked by the plugin + const componentNameList = state.trackedComponents + ? Object.keys(state.trackedComponents) + : []; + + // Some components need specific handlers present (inject no-op handlers if missing) ensureMandatoryAttributes( path, componentName, @@ -63,30 +100,49 @@ export function handleJSXElementActionPaths( actionPathNames ); + // Optionally compute a content getter (children + label props) + setContentAttribute(componentName, t, path, state, ddValues); + + // Wrap every actionable handler attribute with RUM for (const attrPath of actionPathList) { attrPath.node.extra = { ...attrPath.node.extra, ddValues }; - - handleRumActions(t, attrPath, state); + handleRumActions(t, attrPath, state, componentNameList); } } +/** + * Ensures that all mandatory handler attributes exist on the element so that + * they can be wrapped by RUM even if the user didn’t specify them. + * + * Example: + * Some inputs require `onFocus`/`onBlur` for reliable action boundaries. + * If missing, we inject `() => {}` as a placeholder and mark those paths + * as actionable so they get wrapped downstream. + * + * @param path JSXElement path. + * @param componentName Host component name for lookup in `tapElementsRequiredAttributesMap`. + * @param actionPathList Collected actionable attribute paths (will be appended to). + * @param actionPathNames Names of actionable attributes already present. + */ export function ensureMandatoryAttributes( path: Babel.NodePath, componentName: string, actionPathList: Babel.NodePath[], actionPathNames: string[] ) { - // Check if we're missing some required attributes + // Resolve any mandatory attributes for this component const requiredAttributes = tapElementsRequiredAttributesMap[componentName]; if (requiredAttributes) { + // Determine missing ones const attrToAdd = requiredAttributes.filter( x => !actionPathNames.includes(x) ); for (const attr of attrToAdd) { + // Inject a no-op handler: () => {} const attribute = jsxAttribute( jsxIdentifier(attr), jsxExpressionContainer( @@ -95,6 +151,7 @@ export function ensureMandatoryAttributes( ); path.node.openingElement.attributes.push(attribute); + // Grab attribute paths and append the new one to action list const attrPaths = path.get( 'openingElement.attributes' ) as Babel.NodePath[]; @@ -106,6 +163,110 @@ export function ensureMandatoryAttributes( } } +/** + * Optionally attaches a `getContent` resolver into `ddValues` that, at runtime, + * returns a string derived from: + * - the element's children (rendered via a JSX Fragment clone) + * - common label-like props (`trackingLabel`, `title`, `label`, `text`, plus an optional custom prop) + * + * @param componentName Host component name (controls whether content is used). + * @param t Babel types helper. + * @param path JSXElement path. + * @param state Plugin state with trackedComponents metadata. + * @param ddValues Mutable map of computed values attached to attributes via `node.extra.ddValues`. + */ +export function setContentAttribute( + componentName: string, + t: typeof Babel.types, + path: Babel.NodePath, + state: PluginPassState, + ddValues: Record< + string, + | Babel.types.ArrayExpression + | Babel.types.ArrowFunctionExpression + | Babel.types.ObjectExpression + > +) { + const componentData = state.trackedComponents?.[componentName]; + if (componentData?.useContent) { + // Potential prop names to get text content from + const LABEL_PROPS = ['trackingLabel', 'title', 'label', 'text']; + + if (componentData?.contentProp) { + LABEL_PROPS.push(componentData.contentProp); + } + + // Retrieve literal/expr values from matching attributes + const candidates: Babel.types.Expression[] = []; + for (const name of LABEL_PROPS) { + const attr = (path.node.openingElement.attributes as ( + | Babel.types.JSXAttribute + | Babel.types.JSXSpreadAttribute + )[]).find( + a => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name }) + ) as Babel.types.JSXAttribute | undefined; + + if (!attr) { + continue; + } + + if (!attr.value) { + continue; // if boolean shorthand - skip + } + + if (t.isStringLiteral(attr.value)) { + candidates.push(attr.value); + } else if (t.isJSXExpressionContainer(attr.value)) { + candidates.push( + attr.value.expression as Babel.types.Expression + ); + } + } + + // Clone children into a fragment so the runtime can render/extract text + const fragment = t.jsxFragment( + t.jSXOpeningFragment(), + t.jSXClosingFragment(), + [...path.node.children.map(x => t.cloneNode(x, true))] + ); + + // Mark to avoid wrapping descendants during traversal + fragment.extra = { + __wrappedForRum: true + }; + + // () => __ddExtractText(<>{children}, [candidates...]) + const getContentNode = t.arrowFunctionExpression( + [], + t.blockStatement([ + t.returnStatement( + t.callExpression(t.identifier('__ddExtractText'), [ + fragment, + t.arrayExpression( + candidates.map(e => t.cloneNode(e, true)) + ) + ]) + ) + ]) + ); + + ddValues.getContent = getContentNode; + } +} + +/** + * Scans a JSXElement and derives: + * - `actionPathList`: attribute paths to wrap (based on configured handler names) + * - `actionPathNames`: the corresponding attribute names + * - `ddValues`: arrays of Datadog-specific attributes (e.g., data-dd-action-name), + * and an `options` object based on the tracked component config + * + * @param componentName Host component name to look up handlers/flags. + * @param t Babel types helper. + * @param path JSXElement path. + * @param state Plugin state. + * @param options Plugin options. + */ export function getJSXElementActionPaths( componentName: string, t: typeof Babel.types, @@ -113,16 +274,50 @@ export function getJSXElementActionPaths( state: PluginPassState, options: PluginOptions ) { + // DD attributes to collect (plus optional custom action name attribute) const ddAttrs = [ ...rumComponentAttributes, options.actionNameAttribute ?? null ].filter(Boolean); - const ddValues: Record = {}; - const actionMapList = state.tapMappings?.[componentName] || []; + // Map for dd attributes and misc options + const ddValues: Record< + string, + | Babel.types.ArrayExpression + | Babel.types.ArrowFunctionExpression + | Babel.types.ObjectExpression + > = {}; + + // Handler names to consider actionable (e.g., onPress, onLongPress) + const actionMapList = + state.trackedComponents?.[componentName]?.handlers.map(x => x.event) || + []; + const actionPathList: Babel.NodePath[] = []; const actionPathNames: string[] = []; + // Add options if this component is tracked + if (state.trackedComponents?.[componentName]) { + ddValues['options'] = t.objectExpression([ + t.objectProperty( + t.stringLiteral('useContent'), + toExpression( + t, + state.trackedComponents?.[componentName].useContent + ) + ), + + t.objectProperty( + t.stringLiteral('useNamePrefix'), + toExpression( + t, + state.trackedComponents?.[componentName].useNamePrefix + ) + ) + ]); + } + + // Traverse attributes to collect DD props and actionable handlers path.traverse({ JSXAttribute(subpath) { if (!subpath.node.extra) { @@ -134,20 +329,28 @@ export function getJSXElementActionPaths( return; } + // Collect literal DD attributes into arrays inside ddValues + // Required for handling `CompoundComponents` const isValidAttr = ddAttrs.includes(attrName); - if (isValidAttr) { const data = subpath.node.value; if (t.isStringLiteral(data)) { - ddValues[attrName] = data.value; + if (!ddValues[attrName]) { + ddValues[attrName] = t.arrayExpression([]); + } + + const valuesArray = ddValues[attrName]; + if (t.isArrayExpression(valuesArray)) { + valuesArray.elements.push(data); + } } return; } + // Accumulate handler attributes that we should wrap const isValidMapping = actionMapList.includes(attrName); - if (isValidMapping) { actionPathNames.push(attrName); actionPathList.push(subpath); @@ -159,29 +362,47 @@ export function getJSXElementActionPaths( return { actionPathList, actionPathNames, ddValues }; } +/** + * Wraps a specific JSXAttribute (e.g., onPress, onLongPress, onCustomAction) with the Datadog RUM handler. + * + * @param t Babel types helper. + * @param path Attribute path to wrap. + * @param state Plugin state. + * @param componentNameList Names of custom components already handled by the plugin. + */ export function handleRumActions( t: typeof Babel.types, path: Babel.NodePath, - state: PluginPassState + state: PluginPassState, + componentNameList: string[] ) { - // If the node was already processed skip the processing step - // When using `path.traverse` inside the `JSXElement` hook and injecting new nodes - // We can get into a situation where the same attribute is set to be processed twice due to parent lookup operations + // Skip if already processed if (path.node?.extra?.__wrappedForRum) { return; } + // Skip if nested in a component we should not track OR + // Custom tracked component (that already wraps internally) + const validParent = checkValidParent(t, path, componentNameList); + if (!validParent) { + return; + } + + // Confirm we have a proper actionable attribute and extract details const { success, result } = checkValidAction(t, path); if (!success || !result) { return; } + // Create a wrapping function to Handler RUM Actions const containerExpression = handleTapAction(path, t, state, result); if (!containerExpression) { return; } + // Replace attribute's value with the wrapping function + // Mark it as wrapped path.node.value = containerExpression; path.node.extra = { ...path.node.extra, @@ -189,6 +410,58 @@ export function handleRumActions( }; } +/** + * Determines whether the attribute’s enclosing component should be wrapped here. + * + * @param t Babel types helper. + * @param path Attribute path. + * @param componentNameList Names of custom components tracked by this plugin. + * @returns `true` if safe to wrap; `false` to skip. + */ +function checkValidParent( + t: typeof Babel.types, + path: Babel.NodePath, + componentNameList: string[] +) { + const predicate = (p: Babel.NodePath) => + p.isFunctionDeclaration() || + p.isVariableDeclaration() || + p.isClassDeclaration(); + + const cPath = path.findParent(p => predicate(p)) || null; + + if (cPath) { + const node = cPath.node; + let parentName: string | null = null; + + if (t.isVariableDeclaration(node)) { + const cNode = node.declarations[0].id; + parentName = getNodeName(t, cNode); + } else if ( + t.isFunctionDeclaration(node) || + t.isClassDeclaration(node) + ) { + const cNode = node.id; + parentName = cNode ? getNodeName(t, cNode) : null; + } + // If the nearest declaration is a tracked component, we skip wrapping here. + if (parentName && componentNameList.includes(parentName)) { + return false; + } + } + + return true; +} + +/** + * Validates that a JSXAttribute is a proper action candidate and extracts the + * essential details needed to build the wrapper. + * + * @param t Babel types helper. + * @param path Attribute path under test. + * @returns `{ success: boolean, result: RumActionResult | null }` + * `success=false` when the attribute isn't suitable for wrapping. + */ function checkValidAction( t: typeof Babel.types, path: Babel.NodePath @@ -206,6 +479,7 @@ function checkValidAction( ? propertyValue.expression : null; + // If any required piece is missing, do not attempt to wrap if ( !parentNode || !parentName || @@ -216,6 +490,7 @@ function checkValidAction( return { success: false, result: null }; } + // Provide the wrapper all the pieces it needs return { success: true, result: { diff --git a/packages/react-native-babel-plugin/src/actions/rum/tap.ts b/packages/react-native-babel-plugin/src/actions/rum/tap.ts index 7f98b2783..43abc5d2f 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/tap.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/tap.ts @@ -8,8 +8,17 @@ import type * as Babel from '@babel/core'; import { RumAction, RumActionConstants } from '../../constants'; import type { PluginPassState, RumActionResult } from '../../types'; -import { getArgumentsFromParams, getNodeName } from '../../utils'; - +import { getArgumentsFromParams, getNodeName, toExpression } from '../../utils'; + +/** + * Wraps a tap handler attribute (e.g., onPress) with RUM Action Tracking wrapper call. + * + * @param path - The JSXAttribute path for the handler prop (e.g., `onPress={...}`). + * @param t - Babel types helper. + * @param state - Plugin state, including tracked components and memoization cache. + * @param actionResult - Extracted info about the attribute (parentName, propertyName, nodes, expression). + * @returns A JSXExpressionContainer with the wrapped handler, or `null/undefined` if skipped. + */ export function handleTapAction( path: Babel.NodePath, t: typeof Babel.types, @@ -23,10 +32,15 @@ export function handleTapAction( propertyNode.value ); - // Check if the property and element is valid by checking our tap mappings list - const mapEntry = state.tapMappings?.[parentName]; + // Check if the property and element are valid by checking our trackedComponents list + const mapEntry = state.trackedComponents?.[parentName]; const isValidElement = !!mapEntry; - const isValidEvent = mapEntry?.includes(propertyName) || false; + const isValidEvent = + mapEntry?.handlers.map(x => x.event).includes(propertyName) || false; + + const handler = state.trackedComponents?.[parentName]?.handlers.find( + x => x.event === propertyName + ); if (!isExpressionContainer || !isValidEvent || !isValidElement) { return; @@ -34,9 +48,7 @@ export function handleTapAction( const isArrowFunc = t.isArrowFunctionExpression(expression); const isNamedFunc = - t.isIdentifier(expression) || - (t.isMemberExpression(expression) && - t.isThisExpression(expression.object)); + t.isIdentifier(expression) || t.isMemberExpression(expression); if (!isArrowFunc && !isNamedFunc) { return; @@ -47,12 +59,23 @@ export function handleTapAction( ? getNamedFunctionNode(path, t, expression, 'Component') : { fName: null, fNode: null }; + const handlerArgs = + isArrowFunc && expression?.params + ? t.arrayExpression( + getArgumentsFromParams(t, state, expression.params).callArgs + ) + : t.arrayExpression([t.spreadElement(t.identifier('args'))]); + + if (path.node?.extra?.ddValues) { + (path.node.extra.ddValues as any)['handlerArgs'] = handlerArgs; + } + const argsObject = t.objectExpression([ ...Object.entries(path.node?.extra?.ddValues || {}).map( ([key, value]) => { return t.objectProperty( t.stringLiteral(key), - t.stringLiteral(value) + toExpression(t, value) ); } ), @@ -76,18 +99,24 @@ export function handleTapAction( expression, fName, fNode, - argsObject + argsObject, + handler?.mode ); - // This is the fallback expression, only used if there is something wrong with the setup on the SDK side - // Even though the function is still wrapped, it will not call any custom logic from our side - // It will simply call the user's function - const returnExpression = isArrowFunc - ? t.callExpression( - expression, - getArgumentsFromParams(t, state, expression.params).callArgs - ) - : t.callExpression(expression, [t.spreadElement(t.identifier('args'))]); + let returnExpression: Babel.types.Expression | null = null; + + if (handler && handler?.mode === 'delayed') { + returnExpression = expression; + } else { + returnExpression = isArrowFunc + ? t.callExpression( + expression, + getArgumentsFromParams(t, state, expression.params).callArgs + ) + : t.callExpression(expression, [ + t.spreadElement(t.identifier('args')) + ]); + } state.hasValidTapAction = true; @@ -100,11 +129,28 @@ export function handleTapAction( expression, expressionParams, returnExpression, - argsObject + argsObject, + handler?.mode ) : null; } +/** + * Attempts to resolve a named function reference within a component or the program scope. + * + * Search Types: + * - 'Component': nearest function/class component body (FunctionDeclaration, FunctionExpression, ClassDeclaration). + * - 'Program': the module/program scope. + * + * Returns the function name, the node (FunctionDeclaration or VariableDeclarator), + * and the function parameters (to support accurate argument forwarding). + * + * @param path - The JSXAttribute path. + * @param t - Babel types helper. + * @param expression - Identifier referencing the function. + * @param type - Search type: 'Component' or 'Program'. + * @returns Object with `{ fName, fNode, fParams }`. + */ function getNamedFunctionNode( path: Babel.NodePath, t: typeof Babel.types, @@ -184,6 +230,19 @@ function getNamedFunctionNode( return { fName, fNode, fParams }; } +/** + * If a handler is memoized via `useCallback` or `useMemo`, wraps the memoized callback instead + * of the JSX prop directly, preserving memoization semantics. + * + * @param path - The JSXAttribute path where the memoized handler is referenced. + * @param t - Babel types helper. + * @param state - Plugin state, with `memoization` map. + * @param fName - The variable name bound to the memoized value. + * @param fNode - The variable declarator or function declaration of the memoized symbol. + * @param argsObject - Datadog-specific args object (ddValues/options). + * @param mode - Optional handler mode; when `'delayed'`, wrapper invocation is deferred. + * @returns `true` if considered memoized (wrapped or intentionally skipped), else `false`. + */ function handleMemoization( path: Babel.NodePath, t: typeof Babel.types, @@ -194,7 +253,8 @@ function handleMemoization( | babel.types.FunctionDeclaration | babel.types.VariableDeclarator | null, - argsObject: babel.types.ObjectExpression + argsObject: babel.types.ObjectExpression, + mode?: string ) { if (!fName || !fNode || t.isFunctionDeclaration(fNode)) { return !!state.memoization?.[fName || '']; @@ -233,14 +293,19 @@ function handleMemoization( )[] ) => { const { callArgs } = getArgumentsFromParams(t, state, params); - const returnExpression = t.callExpression(callback, callArgs); + const returnExpression = + mode === 'delayed' + ? callback + : t.callExpression(callback, callArgs); + const actionWrapper = getActionWrapperFunction( t, state, callback, params, returnExpression, - argsObject + argsObject, + mode ); varInit.arguments.fill(actionWrapper, 0, 1); fNode.init = varInit; @@ -304,6 +369,21 @@ function handleMemoization( return !!state.memoization?.[fName]; } +/** + * Builds a JSXExpressionContainer containing an arrow function wrapper for the handler. + * + * This simply delegates to `getActionWrapperFunction` and then wraps the result + * as a JSX expression for use directly as a prop value. + * + * @param t - Babel types helper. + * @param state - Plugin state. + * @param expression - The original handler expression (arrow fn or identifier/member). + * @param expressionParams - Parameters of the original function, if known. + * @param returnExpression - Expression to execute when not using the RUM wrapper (fallback). + * @param argsObject - Datadog-specific args object (ddValues/options). + * @param mode - Optional handler mode; when `'delayed'`, wrapper invocation is deferred. + * @returns A JSXExpressionContainer that evaluates to the wrapped handler arrow function. + */ function getActionWrapperNode( t: typeof Babel.types, state: PluginPassState, @@ -316,7 +396,8 @@ function getActionWrapperNode( )[] | null, returnExpression: Babel.types.Expression, - argsObject: Babel.types.ObjectExpression + argsObject: Babel.types.ObjectExpression, + mode?: string ) { const actionWrapperFunction = getActionWrapperFunction( t, @@ -324,11 +405,31 @@ function getActionWrapperNode( expression, expressionParams, returnExpression, - argsObject + argsObject, + mode ); return t.jsxExpressionContainer(actionWrapperFunction); } +/** + * Constructs the actual wrapper arrow function that: + * - computes/normalizes parameters and pre-call statements, + * - attempts to call the RUM wrapper if the runtime instance is available, + * - otherwise falls back to calling the original handler directly. + * + * In 'delayed' mode, returns a function that defers invocation — the RUM wrapper + * returns a callable which is later called with `callArgs`. Otherwise, it invokes + * the wrapper immediately with `callArgs`. + * + * @param t - Babel types helper. + * @param state - Plugin state (used by argument computation utilities). + * @param expression - Original handler expression (arrow/name/member). + * @param expressionParams - Original handler parameters, if known. If null, uses `...args`. + * @param returnExpression - Fallback: direct call (or the function itself in delayed mode). + * @param argsObject - Datadog-specific args object (ddValues/options). + * @param mode - Optional handler mode; when `'delayed'`, wrapper invocation is deferred. + * @returns An ArrowFunctionExpression that implements the described behavior. + */ function getActionWrapperFunction( t: typeof Babel.types, state: PluginPassState, @@ -341,7 +442,8 @@ function getActionWrapperFunction( )[] | null, returnExpression: Babel.types.Expression, - argsObject: Babel.types.ObjectExpression + argsObject: Babel.types.ObjectExpression, + mode?: string ) { const params = expressionParams || [t.restElement(t.identifier('args'))]; @@ -351,6 +453,28 @@ function getActionWrapperFunction( callArgs } = getArgumentsFromParams(t, state, params); + const wrapperExpression = t.callExpression( + t.memberExpression( + t.callExpression( + t.memberExpression( + t.identifier(RumActionConstants.ACTION_CLASS), + t.identifier(RumActionConstants.ACTION_CLASS_INSTANCE) + ), + [] + ), + t.identifier(RumActionConstants.ACTION_FUNCTION_WRAPPER) + ), + [expression, t.stringLiteral(RumAction.TAP), argsObject] + ); + + const wrapperExpressionImmediate = t.callExpression( + wrapperExpression, + callArgs + ); + + const outputExpression = + mode === 'delayed' ? wrapperExpression : wrapperExpressionImmediate; + return t.arrowFunctionExpression( wrapperParams, t.blockStatement([ @@ -363,35 +487,7 @@ function getActionWrapperFunction( ), [] ), - t.returnStatement( - t.callExpression( - t.callExpression( - t.memberExpression( - t.callExpression( - t.memberExpression( - t.identifier( - RumActionConstants.ACTION_CLASS - ), - t.identifier( - RumActionConstants.ACTION_CLASS_INSTANCE - ) - ), - [] - ), - t.identifier( - RumActionConstants.ACTION_FUNCTION_WRAPPER - ) - ), - [ - expression, - t.stringLiteral(RumAction.TAP), - argsObject - ] - ), - callArgs - // [t.spreadElement(t.identifier('args'))] - ) - ), + t.returnStatement(outputExpression), t.returnStatement(returnExpression) ) ]) diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index c82a5bfc0..d5968dd87 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -12,6 +12,7 @@ import { handleJSXElementActionPaths, insertRumActionImport } from './actions/rum'; +import { defaultPluginOptions } from './constants'; import type { PluginAPI, PluginOptions, @@ -21,18 +22,23 @@ import type { import { getFileInfo, getNodeName } from './utils/index'; export default declare( - ( - api: PluginAPI, - options: PluginOptions, - _dirname: string - ): PluginResult => { + (api: PluginAPI, opt: PluginOptions, _dirname: string): PluginResult => { api.assertVersion(7); + const options = { + ...opt, + components: { + ...defaultPluginOptions.components, + ...opt.components + } + }; + return { visitor: { Program: { enter(path, state) { const pluginState: PluginPassState = state; + const { path: p, name } = getFileInfo(this); if (p?.includes('node_modules')) { @@ -41,8 +47,29 @@ export default declare( pluginState.fileInfo = { path: p, name }; - loadImportMap(path, api.types, pluginState); insertSetupFlag(path, state, api.types); + loadImportMap(path, api.types, pluginState, options); + + if (!pluginState.trackedComponents) { + pluginState.trackedComponents = {}; + } + + for (const entry of options.components.tracked) { + pluginState.trackedComponents[entry.name] = { + useContent: + entry.useContent !== undefined + ? entry.useContent + : options.components.useContent, + useNamePrefix: + entry.useNamePrefix !== undefined + ? entry.useNamePrefix + : options.components.useNamePrefix, + ...(entry.contentProp + ? { contentProp: entry.contentProp } + : {}), + handlers: entry.handlers + }; + } }, exit(path, state) { const pluginState: PluginPassState = state; diff --git a/packages/react-native-babel-plugin/src/utils/PluginState.ts b/packages/react-native-babel-plugin/src/utils/PluginState.ts index c206fa7ce..0cd27b60f 100644 --- a/packages/react-native-babel-plugin/src/utils/PluginState.ts +++ b/packages/react-native-babel-plugin/src/utils/PluginState.ts @@ -39,8 +39,8 @@ export class PluginState { private getPlatform() { return ( - // @ts-ignore - (this.state?.file?.opts?.caller?.platform as string) || 'unknown' + ((this.state?.file?.opts?.caller as any)?.platform as string) || + 'unknown' ); } diff --git a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts index 51980c1b6..cf4135c42 100644 --- a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts +++ b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts @@ -8,6 +8,12 @@ import type * as Babel from '@babel/core'; import type { AssignmentNode, PluginPassState } from '../types'; +/** + * Inserts a node at the very top of a Program body. + * + * @param path - Program path to mutate. + * @param node - Statement or module declaration to unshift into `body`. + */ export function insertAtProgramTop( path: Babel.NodePath, node: Babel.types.Statement | Babel.types.ModuleDeclaration @@ -15,17 +21,34 @@ export function insertAtProgramTop( path.unshiftContainer('body', node); } +/** + * Creates a named import declaration for a given module. + * + * Given: `getImportDeclaration(t, ['foo', 'bar'], 'pkg')` + * Results: `import { foo, bar } from 'pkg';` + * + * @param t - Babel types helper. + * @param data - List of named specifiers to import. + * @param module - Module source string. + * @returns `ImportDeclaration` AST node. + */ export function getImportDeclaration( t: typeof Babel.types, - data: string, + data: string[], module: string ) { - return t.importDeclaration( - [t.importSpecifier(t.identifier(data), t.identifier(data))], - t.stringLiteral(module) + const nodeData = data.map(x => + t.importSpecifier(t.identifier(x), t.identifier(x)) ); + return t.importDeclaration(nodeData, t.stringLiteral(module)); } +/** + * Extracts filename and directory from Babel's PluginPass. + * + * @param data - Babel plugin pass object. + * @returns Object with the file `path` (directory) and `name` (basename). Nulls if unavailable. + */ export function getFileInfo(data: Babel.PluginPass) { const result: { path: string | null; name: string | null } = { path: null, @@ -43,6 +66,16 @@ export function getFileInfo(data: Babel.PluginPass) { return result; } +/** + * Resolves a readable name for various identifier-like nodes. + * + * Supports: Identifier, JSXIdentifier, JSXNamespacedName, JSXMemberExpression, + * as well as a plain string. + * + * @param t - Babel types helper. + * @param node - Node or string to resolve. + * @returns The resolved name or `null` if not applicable. + */ export function getNodeName( t: typeof Babel.types, node: Babel.types.Node | string @@ -97,6 +130,15 @@ export function getNodeName( return null; } +/** + * Builds an assignment expression statement: `objectKey.propertyKey = value`. + * + * @param t - Babel types helper. + * @param objectKey - Identifier name for the left-hand object. + * @param propertyKey - Identifier name for the left-hand property. + * @param value - Right-hand expression/value to assign. + * @returns `ExpressionStatement` with an `AssignmentExpression`. + */ export function getAssignmentNode( t: typeof Babel.types, objectKey: string, @@ -117,6 +159,22 @@ export function getAssignmentNode( return node; } +/** + * Gets wrapper-call argument wiring from original function parameters. + * + * Returns: + * - `callArgs`: expressions to pass when invoking the original function, + * - `preCallStatements`: statements (e.g., destructuring temps) to run before invocation, + * - `wrapperParams`: parameters for the wrapper arrow function. + * + * Supports identifiers, default params, rest elements, and destructured patterns. + * + * @param t - Babel types helper. + * @param state - Plugin state (used for error context). + * @param params - Original function parameters. + * @returns `{ callArgs, preCallStatements, wrapperParams }`. + * @throws If a parameter type is unsupported. + */ export function getArgumentsFromParams( t: typeof Babel.types, state: PluginPassState, @@ -168,3 +226,65 @@ export function getArgumentsFromParams( wrapperParams }; } + +/** + * Converts a variety of JS values or AST nodes to a valid `Expression`. + * + * Rules: + * - primitives → literal expressions, + * - existing `Expression` → returned as-is, + * - `SpreadElement` → wrapped into a single-element array expression, + * - arrays of nodes → array expression (non-expressions become `undefined` identifiers), + * - fallback → `null`. + * + * @param t - Babel types helper. + * @param v - Value or node to convert. + * @returns `Expression` node. + */ +export function toExpression( + t: typeof Babel.types, + v: unknown +): Babel.types.Expression { + if (typeof v === 'string') { + return t.stringLiteral(v); + } + + if (typeof v === 'boolean') { + return t.booleanLiteral(v); + } + + if (typeof v === 'number') { + return t.numericLiteral(v); + } + + if (t.isExpression(v as any)) { + return v as Babel.types.Expression; + } + + if (t.isSpreadElement?.(v as any)) { + // Spreads can’t be used as a property value; wrap them in an array + return t.arrayExpression([v as Babel.types.SpreadElement]); + } + + if (Array.isArray(v)) { + const nodes = v as Babel.types.Node[]; + const elements: Babel.types.Expression[] = []; + + for (const n of nodes) { + if (t.isExpression(n as any)) { + elements.push(n as Babel.types.Expression); + } else if (t.isSpreadElement?.(n as any)) { + elements.push( + t.arrayExpression([n as Babel.types.SpreadElement]) + ); + } else { + // Unexpected entries we may try to push + elements.push(t.identifier('undefined')); + } + } + + return t.arrayExpression(elements); + } + + return t.nullLiteral(); +} From 936a45beda325a4e1e6243628504a9c738b4d1df Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Wed, 3 Sep 2025 11:42:34 +0100 Subject: [PATCH 5/6] Update Babel Plugin's unit tests to accommodate for plugin options --- .../test/plugin.test.ts | 335 ++++++++++++++++-- 1 file changed, 302 insertions(+), 33 deletions(-) diff --git a/packages/react-native-babel-plugin/test/plugin.test.ts b/packages/react-native-babel-plugin/test/plugin.test.ts index 02ab66d9b..7e02d676b 100644 --- a/packages/react-native-babel-plugin/test/plugin.test.ts +++ b/packages/react-native-babel-plugin/test/plugin.test.ts @@ -49,12 +49,20 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Button } from 'react-native'; /*#__PURE__*/React.createElement(Button, { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Button" })(...args);else return func(...args); } @@ -75,7 +83,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { TextInput } from 'react-native'; /*#__PURE__*/React.createElement(TextInput, { placeholder: "Enter username", @@ -86,6 +94,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => { console.log('test'); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], "componentName": "TextInput" })();else return (() => { console.log('test'); @@ -107,7 +123,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { TextInput } from 'react-native'; /*#__PURE__*/React.createElement(TextInput, { placeholder: "Enter username", @@ -116,6 +132,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { style: styles.input, onFocus: () => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => {}, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], "componentName": "TextInput" })();else return (() => {})(); } @@ -132,13 +156,21 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: event => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(event => { console.log('Testing: ', event); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [event], "componentName": "Pressable" })(event);else return (event => { console.log('Testing: ', event); @@ -158,7 +190,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: (test1, test2) => { @@ -166,6 +198,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { console.log('Test1: ', test1); console.log('Test2: ', test2); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [test1, test2], "componentName": "Pressable" })(test1, test2);else return ((test1, test2) => { console.log('Test1: ', test1); @@ -186,7 +226,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; const func = event => { console.log('Testing: ', event); @@ -195,6 +235,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return func(...args); } @@ -212,7 +260,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; function func3() { console.log('Testing 3'); @@ -221,6 +269,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func3, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return func3(...args); } @@ -238,7 +294,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; function a(event, data = 1, ...rest) { console.log(event, data, rest); @@ -246,6 +302,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { /*#__PURE__*/React.createElement(Pressable, { onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(a, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return a(...args); } @@ -262,7 +326,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: _dd_arg0 => { @@ -274,6 +338,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }) => { console.log(nativeEvent); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [_dd_arg0], "componentName": "Pressable" })(_dd_arg0);else return (({ nativeEvent @@ -294,7 +366,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: (_dd_arg0, extra, _dd_arg2, ...rest) => { @@ -307,6 +379,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, extra, [x, y], ...rest) => { console.log(nativeEvent, extra, x, y, rest); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [_dd_arg0, extra, _dd_arg2, ...rest], "componentName": "Pressable" })(_dd_arg0, extra, _dd_arg2, ...rest);else return (({ nativeEvent @@ -327,13 +407,21 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: (event, context = 'default') => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction((event, context = 'default') => { console.log(event, context); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [event, context], "componentName": "Pressable" })(event, context);else return ((event, context = 'default') => { console.log(event, context); @@ -352,7 +440,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; /*#__PURE__*/React.createElement(Pressable, { onPress: _dd_arg0 => { @@ -364,6 +452,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }) => { console.log(x); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [_dd_arg0], "componentName": "Pressable" })(_dd_arg0);else return (({ x = 1 @@ -392,7 +488,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; const handler = test => { console.log('Testing ', test); @@ -402,6 +498,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(handler, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return handler(...args); } @@ -423,13 +527,21 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Pressable } from 'react-native'; function MyComponent() { return /*#__PURE__*/React.createElement(Pressable, { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(handler, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return handler(...args); } @@ -451,17 +563,70 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { Pressable } from 'react-native'; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; + import { Pressable } from 'react-native'; function MyComponent() { return /*#__PURE__*/React.createElement(Pressable, { color: "red", - onPress: globalThis.handler + onPress: (...args) => { + if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(globalThis.handler, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], + "componentName": "Pressable" + })(...args);else return globalThis.handler(...args); + } }); }" `); }); it('should wrap arrow function when given function reference as prop', () => { + const input = ` + import React from 'react'; + import { View, Pressable } from 'react-native'; + + function MyComponent(props) { + return( + + + + ); + } + `; + + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; + import React from 'react'; + import { View, Pressable } from 'react-native'; + function MyComponent(props) { + return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { + color: "red", + onPress: (...args) => { + if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(props.onPress, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], + "componentName": "Pressable" + })(...args);else return props.onPress(...args); + } + })); + }" + `); + }); + + it('should wrap arrow function when given function reference as prop destructure', () => { const input = ` import React from 'react'; import { View, Pressable } from 'react-native'; @@ -477,7 +642,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; function MyComponent({ @@ -488,6 +653,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(onPress, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(...args);else return onPress(...args); } @@ -512,7 +685,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; function MyComponent({ @@ -523,6 +696,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: () => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => onPress(item.id), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], "componentName": "Pressable" })();else return (() => onPress(item.id))(); } @@ -553,7 +734,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Button } from 'react-native'; class MyClassComponent2 extends Component { @@ -570,6 +751,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { title: "Press Me", onPress: () => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => this.handlePress(), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), ["Press Me"]); + }, + "handlerArgs": [], "componentName": "Button" })();else return (() => this.handlePress())(); } @@ -601,7 +790,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Button } from 'react-native'; class MyClassComponent extends Component { @@ -618,6 +807,14 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { title: "Press Me", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(this.handlePress, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), ["Press Me"]); + }, + "handlerArgs": [...args], "componentName": "Button" })(...args);else return this.handlePress(...args); } @@ -639,7 +836,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { Button } from 'react-native'; /*#__PURE__*/React.createElement(Button, { "dd-action-name": "test-action-button", @@ -648,9 +845,17 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { color: "red", onPress: (...args) => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", { - "dd-action-name": "test-action-button", - "example-button-prop": "action-name-attr-button", - "accessibilityLabel": "accessibility-action-button", + "options": { + "useContent": true, + "useNamePrefix": true + }, + "dd-action-name": ["test-action-button"], + "example-button-prop": ["action-name-attr-button"], + "accessibilityLabel": ["accessibility-action-button"], + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [...args], "componentName": "Button" })(...args);else return func(...args); } @@ -680,7 +885,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { useCallback } from 'react'; import { Pressable } from 'react-native'; function MyComponent() { @@ -690,6 +895,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', setA(x => x + 1); setB(x => x + 1); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(event);else return (event => { console.log('Testing ', a, b, event); @@ -725,7 +938,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { useCallback } from 'react'; import { Pressable } from 'react-native'; function MyComponent() { @@ -738,6 +951,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', color: "red", onPress: () => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => handler('Test'), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], "componentName": "Pressable" })();else return (() => handler('Test'))(); } @@ -764,7 +985,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { useCallback } from 'react'; import { Pressable } from 'react-native'; function MyComponent() { @@ -772,6 +993,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => { console.log('Testing '); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })();else return (() => { console.log('Testing '); @@ -805,7 +1034,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', `; const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { useCallback } from 'react'; import { View, Pressable } from 'react-native'; function MyComponent() { @@ -813,6 +1042,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => { console.log('Testing '); }, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })();else return (() => { console.log('Testing '); @@ -853,7 +1090,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import { useCallback } from 'react'; import { View, Pressable } from 'react-native'; function MyComponent() { @@ -864,6 +1101,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }; const handler = useCallback(test => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(funcN, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(test);else return funcN(test); }, [a, b]); @@ -899,7 +1144,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; function MyComponent() { @@ -910,6 +1155,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }; const handler = React.useCallback(test => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(funcN, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(test);else return funcN(test); }, [a, b]); @@ -943,7 +1196,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; const funcN = test => { @@ -952,6 +1205,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', function MyComponent() { const handler = React.useCallback(test => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(funcN, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })(test);else return funcN(test); }, [a, b]); @@ -982,7 +1243,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; import { funcN } from '../myFile'; @@ -1018,7 +1279,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', const output = transformCode(input); expect(output).toMatchInlineSnapshot(` - "import { DdBabelInteractionTracking } from "@datadog/mobile-react-native"; + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; import React from 'react'; import { View, Pressable } from 'react-native'; const funcN = () => { @@ -1027,6 +1288,14 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', function MyComponent() { const handler = React.useMemo(() => { if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(funcN, "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(<>, []); + }, + "handlerArgs": [...args], "componentName": "Pressable" })();else return funcN(); }, []); From 9ab4982918e150afe14456dc72bf4a2db17fd279 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Wed, 3 Sep 2025 13:27:32 +0100 Subject: [PATCH 6/6] Update Babel plugin's README.md to include new configuration options --- packages/react-native-babel-plugin/README.md | 81 ++++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/react-native-babel-plugin/README.md b/packages/react-native-babel-plugin/README.md index 4c9c24a74..e83d36fff 100644 --- a/packages/react-native-babel-plugin/README.md +++ b/packages/react-native-babel-plugin/README.md @@ -1,10 +1,10 @@ # Babel Plugin for React Native -The `@datadog/mobile-react-native-babel-plugin` enhances the Datadog React Native SDK by automatically enriching React components with contextual metadata. It helps improve the accuracy of features such as RUM event correlation, Session Replay, and UI tracking. +The `@datadog/mobile-react-native-babel-plugin` enhances the Datadog React Native SDK by automatically enriching React components with contextual metadata. This helps improve the accuracy of features such as RUM Action tracking and Session Replay. ## Setup -**Note**: Make sure you’ve already integrated the [Datadog React Native SDK][1]. +**Note**: Make sure you've already integrated the [Datadog React Native SDK][1]. To install with NPM, run: @@ -22,7 +22,7 @@ yarn add @datadog/mobile-react-native-babel-plugin Add the plugin to your Babel configuration. Depending on your setup, you might be using a `babel.config.js`, `.babelrc`, or similar. -Example configuration: +**Example configuration:** ```js module.exports = { @@ -31,7 +31,53 @@ module.exports = { }; ``` -If you are currently using `actionNameAttribute` in your datadog SDK configuration, you'll need to also specify it here: +### Configuration Options + +You can configure the plugin to adjust how it processes your code, giving you control over its behavior and allowing you to tailor it to your project’s needs. + +#### Top-level options + +| Option | Type | Default | Description | +|-----------------------|--------|---------|-------------| +| `actionNameAttribute` | string | – | The chosen attribute name to use for action names. | +| `components` | object | – | Component tracking configuration. | + +--- + +#### `components` options + +| Option | Type | Default | Description | +|-----------------|---------|---------|-------------| +| `useContent` | boolean | true | Whether to use component content (for example: children, props) as the action name. | +| `useNamePrefix` | boolean | true | Whether to prefix actions with the component name. | +| `tracked` | array | – | List of component-specific tracking configs. | + +--- + +#### `components.tracked[]` (per component) + +Each entry in the `tracked` array is an object with the following shape: + +| Option | Type | Default | Description | +|-----------------|---------|----------------------|-------------| +| `name` | string | – | The component name to track (e.g., `Button`). | +| `useContent` | boolean | inherits from global | Override `useContent` for this component. | +| `useNamePrefix` | boolean | inherits from global | Override `useNamePrefix` for this component. | +| `contentProp` | string | – | Property name to use for content instead of children (for example: `"subTitle"`). | +| `handlers` | array | – | List of event/action pairs to track. | + +--- + +#### `components.tracked[].handlers[]` + +| Field | Type | Description | +|---------|--------|-------------| +| `event` | string | The event name to intercept (such as `"onPress"`). | +| `action`| string | The RUM action name to associate with this event. _(Only `"TAP"` actions are currently supported)_ | + +--- + +**Example configuration (_using configuration options_):** ```js module.exports = { @@ -40,12 +86,37 @@ module.exports = { [ '@datadog/mobile-react-native-babel-plugin', {actionNameAttribute: 'custom-prop-value'}, + { + components: { + useContent: true, + useNamePrefix: true, + tracked: [ + { + name: 'CustomButton', + contentProp: 'text' + handlers: [{event: 'onPress', action: 'TAP'}], + }, + { + name: 'CustomTextInput', + handlers: [{event: 'onFocus', action: 'TAP'}], + }, + { + useNamePrefix: false, + useContent: false, + name: 'Tab', + handlers: [{event: 'onChange', action: 'TAP'}], + }, + ], + }, + }, ], ], }; ``` -For more recent React Native versions this should be all that is needed. However, if you're on an older version and using Typescript in your project, you may need to install the preset `@babel/preset-typescript`. +## Troubleshooting + +**Note**: If you're on an older React Native version, and using Typescript in your project, you may need to install the preset `@babel/preset-typescript`. To install with NPM, run: