diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts index 4cb146783..e92d12e1a 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts @@ -76,41 +76,48 @@ class BabelInteractionTracking { const { useAccessibilityLabel } = this.config; - const tryContent = () => { - const content = getContent?.(); - if (content && content.length > 0) { - return content; + const index = handlerArgs + ? handlerArgs.find(x => typeof x === 'number') || 0 + : 0; + + // Picks the value at `index` (positions map to compound component parts). + // Values can come from runtime expressions, so a value that doesn't resolve + // to a usable name returns null and lets the next naming source take over. + const getIndexedValue = (values: unknown): string | null => { + if (!Array.isArray(values) || values.length === 0) { + return null; + } + + // Fail-safe in case the our 'index' value turns out to not be a real index + const value = + index + 1 > values.length || index < 0 + ? values[0] + : values[index]; + + if ( + (typeof value === 'string' && value.length > 0) || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return String(value); } return null; }; const getAccessibilityLabel = () => - useAccessibilityLabel && accessibilityLabel - ? accessibilityLabel - : null; - - const index = handlerArgs - ? handlerArgs.find(x => typeof x === 'number') || 0 - : 0; + useAccessibilityLabel ? getIndexedValue(accessibilityLabel) : null; // Order: actionName → actionNameAttribute → accessibilityLabel → content - const selectedContent = - actionName || - Object.values(attrs)[0] || - getAccessibilityLabel() || - tryContent(); + const output = + getIndexedValue(actionName) ?? + getIndexedValue(Object.values(attrs)[0]) ?? + getAccessibilityLabel() ?? + getIndexedValue(getContent?.()); - if (!selectedContent) { + if (output === null) { 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; diff --git a/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts index c74b86ba5..2a2e93e8b 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts +++ b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts @@ -198,3 +198,83 @@ describe('DdBabelInteractionTracking.wrapRumAction', () => { ); }); }); + +describe('DdBabelInteractionTracking target name resolution', () => { + const mockAddAction = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + mockAddAction.mockClear(); + DdBabelInteractionTracking.config = { + trackInteractions: true, + useAccessibilityLabel: true + }; + DdBabelInteractionTracking.attachRumInstance({ + addAction: mockAddAction + } as any); + }); + + const getTargetName = (targetObject: Record) => { + DdBabelInteractionTracking.wrapRumAction(jest.fn(), RumActionType.TAP, { + ...mockTargetObject, + options: { useContent: true, useNamePrefix: false }, + ...targetObject + } as any)(); + return mockAddAction.mock.calls[0][1]; + }; + + it('should use the actionNameAttribute value', () => { + expect(getTargetName({ testID: ['save_button'] })).toBe('save_button'); + }); + + it('should skip undefined and empty actionNameAttribute values', () => { + expect( + getTargetName({ + testID: [undefined, ''], + accessibilityLabel: ['Save'] + }) + ).toBe('Save'); + }); + + it('should skip empty dd-action-name and fall back to actionNameAttribute', () => { + expect( + getTargetName({ 'dd-action-name': [], testID: ['save_button'] }) + ).toBe('save_button'); + }); + + it('should fall back to content when no attribute resolves', () => { + expect( + getTargetName({ + testID: [null], + accessibilityLabel: [undefined], + getContent: () => ['Save content'] + }) + ).toBe('Save content'); + }); + + it('should fall back to the component name when nothing resolves', () => { + expect(getTargetName({ testID: [undefined] })).toBe('Button'); + }); + + it('should stringify numeric values', () => { + expect(getTargetName({ testID: [42] })).toBe('42'); + }); + + it('should keep positions when resolving the indexed value', () => { + expect( + getTargetName({ + testID: ['first', undefined, 'third'], + handlerArgs: [2] + }) + ).toBe('third'); + }); + + it('should fall back to the next source when the indexed value is unresolved', () => { + expect( + getTargetName({ + testID: ['first', undefined, 'third'], + accessibilityLabel: ['a11y_first', 'a11y_second'], + handlerArgs: [1] + }) + ).toBe('a11y_second'); + }); +}); 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 5e9bb9e66..aee4e292d 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/index.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/index.ts @@ -28,11 +28,19 @@ import { getImportDeclaration, getNodeName, insertAtProgramTop, + isExpressionInScope, toExpression } from '../../utils'; import { handleTapAction } from './tap'; +type DdValues = Record< + string, + | Babel.types.ArrayExpression + | Babel.types.ArrowFunctionExpression + | Babel.types.ObjectExpression +>; + /** * Inserts RUM Action Tracking import at the top of the Program. * @@ -85,7 +93,9 @@ export function handleJSXElementActionPaths( const { actionPathList, actionPathNames, - ddValues + ddValues, + hoistedDdValues, + factoryDdValues } = getJSXElementActionPaths(componentName, t, path, state, options); // Create known custom components list tracked by the plugin @@ -116,13 +126,25 @@ export function handleJSXElementActionPaths( const programPath = path.findParent(p => p.isProgram() ) as Babel.NodePath; - setContentAttribute(componentName, t, path, state, ddValues, programPath); + setContentAttribute( + componentName, + t, + path, + state, + ddValues, + hoistedDdValues, + factoryDdValues, + getUseMemoStart(t, path, state, componentName), + programPath + ); // Wrap every actionable handler attribute with RUM for (const attrPath of actionPathList) { attrPath.node.extra = { ...attrPath.node.extra, - ddValues + ddValues, + hoistedDdValues, + factoryDdValues }; handleRumActions(t, attrPath, state, componentNameList); } @@ -384,30 +406,77 @@ function jsxChildToRuntimeCall( return null; } +/** + * Replaces any JSX left anywhere inside `node` (callbacks, non-JSX ternary branches, + * attribute values, ...) with jsx()/jsxs() runtime calls, mutating it in place. + * `jsxChildToRuntimeCall` only converts JSX it reaches directly, and the JSX transform + * never visits code hoisted into memoization hooks, so leftovers would end up as + * raw JSX in the bundle. + * + * @param t Babel types helper. + * @param node Detached (cloned) expression to convert. + * @param programPath Program path for inserting jsx-runtime imports. + * @returns The converted expression (a new node if `node` itself is JSX). + */ +function convertNestedJsx( + t: typeof Babel.types, + node: Babel.types.Expression, + programPath: Babel.NodePath +): Babel.types.Expression { + if (t.isJSXElement(node) || t.isJSXFragment(node)) { + const converted = jsxChildToRuntimeCall(t, node, programPath); + return converted ? convertNestedJsx(t, converted, programPath) : node; + } + + const record = (node as unknown) as Record; + const isNode = (x: unknown): x is Babel.types.Expression => + !!x && typeof (x as Babel.types.Node).type === 'string'; + + for (const key of t.VISITOR_KEYS[node.type] || []) { + const value = record[key]; + if (Array.isArray(value)) { + value.forEach((item, i) => { + if (isNode(item)) { + value[i] = convertNestedJsx(t, item, programPath); + } + }); + } else if (isNode(value)) { + record[key] = convertNestedJsx(t, value, programPath); + } + } + + return node; +} + /** * Optionally attaches a `getContent` resolver into `ddValues` that, at runtime, * returns a string derived from: * - the element's children (rendered via a React.createElement(Fragment) call) * - 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`. - * @param programPath Program path for inserting jsx-runtime imports. + * Additional resolvers are attached into `hoistedDdValues` and `factoryDdValues`, leaving + * out any candidate or children that reference bindings not visible from the component + * body (e.g., `.map()` params) or, for `useMemo` factories, not yet initialized at the hook. + * + * @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`. + * @param hoistedDdValues Mutable map of values used when the args object is hoisted into `useCallback`. + * @param factoryDdValues Mutable map of values used when the args object is hoisted into a `useMemo` factory. + * @param useMemoStart Source position of the `useMemo` call used by this element's handlers, if any. + * @param programPath Program path for inserting jsx-runtime imports. */ 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 - >, + ddValues: DdValues, + hoistedDdValues: DdValues, + factoryDdValues: DdValues, + useMemoStart: number | undefined, programPath: Babel.NodePath ) { const componentData = state.trackedComponents?.[componentName]; @@ -419,17 +488,44 @@ export function setContentAttribute( LABEL_PROPS.push(componentData.contentProp); } + const attrPaths = path.get( + 'openingElement.attributes' + ) as Babel.NodePath< + Babel.types.JSXAttribute | Babel.types.JSXSpreadAttribute + >[]; + + // Where the args object ends up for memoized handlers + const hoistPath = getHoistTargetPath(path); + + // One resolver per args object, each keeping only what is valid where it runs + const variants: { + values: DdValues; + isValid: (p: Babel.NodePath) => boolean; + candidates: Babel.types.Expression[]; + }[] = [ + { values: ddValues, isValid: () => true, candidates: [] }, + { + values: hoistedDdValues, + isValid: p => isExpressionInScope(p, hoistPath), + candidates: [] + }, + { + values: factoryDdValues, + isValid: p => isExpressionInScope(p, hoistPath, useMemoStart), + candidates: [] + } + ]; + // 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) { + const attrPath = attrPaths.find( + a => + t.isJSXAttribute(a.node) && + t.isJSXIdentifier(a.node.name, { name }) + ) as Babel.NodePath | undefined; + + const attr = attrPath?.node; + if (!attrPath || !attr) { continue; } @@ -438,11 +534,25 @@ export function setContentAttribute( } if (t.isStringLiteral(attr.value)) { - candidates.push(attr.value); + const value = attr.value; + variants.forEach(v => v.candidates.push(value)); } else if (t.isJSXExpressionContainer(attr.value)) { - candidates.push( - attr.value.expression as Babel.types.Expression + const expression = convertNestedJsx( + t, + t.cloneNode( + attr.value.expression as Babel.types.Expression, + true + ), + programPath ); + + const exprPath = attrPath.get( + 'value.expression' + ) as Babel.NodePath; + + variants + .filter(v => v.isValid(exprPath)) + .forEach(v => v.candidates.push(expression)); } } @@ -451,58 +561,99 @@ export function setContentAttribute( // plugins, so any JSX nodes created here would survive untransformed. const convertedChildren = path.node.children .map(child => jsxChildToRuntimeCall(t, child, programPath)) - .filter((x): x is Babel.types.Expression => x !== null); - - const fragmentId = addNamed( - programPath, - 'Fragment', - 'react/jsx-runtime' - ); - - const childrenProp = - convertedChildren.length === 1 - ? t.objectProperty( - t.identifier('children'), - convertedChildren[0] - ) - : convertedChildren.length > 1 - ? t.objectProperty( - t.identifier('children'), - t.arrayExpression(convertedChildren) - ) - : null; - - const fragmentProps = childrenProp - ? t.objectExpression([childrenProp]) - : t.objectExpression([]); + .filter((x): x is Babel.types.Expression => x !== null) + .map(child => convertNestedJsx(t, child, programPath)); + + // Share jsx-runtime imports between all resolvers + const runtimeIds: Record = {}; + const getRuntimeId = (name: string) => { + if (!runtimeIds[name]) { + runtimeIds[name] = addNamed( + programPath, + name, + 'react/jsx-runtime' + ) as Babel.types.Identifier; + } + return runtimeIds[name]; + }; - const runtimeFn = - convertedChildren.length > 1 - ? addNamed(programPath, 'jsxs', 'react/jsx-runtime') - : addNamed(programPath, 'jsx', 'react/jsx-runtime'); + // Each variant is stricter than the previous one, so equal selections share a node + const resolvers: Record< + string, + Babel.types.ArrowFunctionExpression + > = {}; + for (const variant of variants) { + const childrenValid = path + .get('children') + .every(child => variant.isValid(child)); + const key = `${variant.candidates.length}:${childrenValid}`; + + if (!resolvers[key]) { + resolvers[key] = getContentNode( + t, + childrenValid ? convertedChildren : [], + variant.candidates, + getRuntimeId + ); + } - const fragment = t.callExpression(t.cloneNode(runtimeFn), [ - t.cloneNode(fragmentId), - fragmentProps - ]); + variant.values.getContent = resolvers[key]; + } + } +} - // () => __ddExtractText(jsxs(Fragment, { 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)) - ) - ]) - ) - ]) - ); +/** + * Builds the `getContent` resolver: + * `() => __ddExtractText(jsxs(Fragment, { children: [...] }), [candidates...])` + * + * @param t Babel types helper. + * @param convertedChildren Children already converted to jsx()/jsxs() runtime calls. + * @param candidates Label-like prop values. + * @param getRuntimeId Returns the local identifier for a `react/jsx-runtime` import. + */ +function getContentNode( + t: typeof Babel.types, + convertedChildren: Babel.types.Expression[], + candidates: Babel.types.Expression[], + getRuntimeId: (name: string) => Babel.types.Identifier +) { + const fragmentId = getRuntimeId('Fragment'); + + const childrenProp = + convertedChildren.length === 1 + ? t.objectProperty(t.identifier('children'), convertedChildren[0]) + : convertedChildren.length > 1 + ? t.objectProperty( + t.identifier('children'), + t.arrayExpression(convertedChildren) + ) + : null; - ddValues.getContent = getContentNode; - } + const fragmentProps = childrenProp + ? t.objectExpression([childrenProp]) + : t.objectExpression([]); + + const runtimeFn = + convertedChildren.length > 1 + ? getRuntimeId('jsxs') + : getRuntimeId('jsx'); + + const fragment = t.callExpression(t.cloneNode(runtimeFn), [ + t.cloneNode(fragmentId), + fragmentProps + ]); + + return t.arrowFunctionExpression( + [], + t.blockStatement([ + t.returnStatement( + t.callExpression(t.identifier('__ddExtractText'), [ + fragment, + t.arrayExpression(candidates.map(e => t.cloneNode(e, true))) + ]) + ) + ]) + ); } /** @@ -532,12 +683,17 @@ export function getJSXElementActionPaths( ].filter(Boolean); // Map for dd attributes and misc options - const ddValues: Record< - string, - | Babel.types.ArrayExpression - | Babel.types.ArrowFunctionExpression - | Babel.types.ObjectExpression - > = {}; + const ddValues: DdValues = {}; + + // Same as `ddValues`, restricted to values that stay valid when the args object + // is hoisted into a `useCallback`/`useMemo` call in the component body + const hoistedDdValues: DdValues = {}; + + // Same as `hoistedDdValues`, restricted to bindings initialized at the hook call + // for `useMemo` factories + const factoryDdValues: DdValues = {}; + + const useMemoStart = getUseMemoStart(t, path, state, componentName); // Handler names to consider actionable (e.g., onPress, onLongPress) const actionMapList = @@ -568,6 +724,11 @@ export function getJSXElementActionPaths( ]); } + if (ddValues['options']) { + hoistedDdValues['options'] = ddValues['options']; + factoryDdValues['options'] = ddValues['options']; + } + // Traverse attributes to collect DD props and actionable handlers path.traverse({ JSXAttribute(subpath) { @@ -580,21 +741,65 @@ export function getJSXElementActionPaths( return; } - // Collect literal DD attributes into arrays inside ddValues + // Collect 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)) { - if (!ddValues[attrName]) { - ddValues[attrName] = t.arrayExpression([]); - } - - const valuesArray = ddValues[attrName]; - if (t.isArrayExpression(valuesArray)) { - valuesArray.elements.push(data); - } + pushDdValue(t, ddValues, attrName, data); + pushDdValue(t, hoistedDdValues, attrName, data); + pushDdValue(t, factoryDdValues, attrName, data); + } else if ( + t.isJSXExpressionContainer(data) && + !t.isJSXEmptyExpression(data.expression) + ) { + // Expressions are evaluated at runtime inside the generated wrapper, + // so they are only forwarded when their bindings are visible there. + // Otherwise an `undefined` placeholder keeps compound component positions. + const exprPath = subpath.get( + 'value.expression' + ) as Babel.NodePath; + const expression = data.expression; + + const isInline = isExpressionInScope(exprPath, path); + const getValue = (isVisible: boolean) => + isVisible + ? t.cloneNode(expression, true) + : t.unaryExpression('void', t.numericLiteral(0)); + + pushDdValue(t, ddValues, attrName, getValue(isInline)); + + // Memoized handlers hoist the args object to the component body + pushDdValue( + t, + hoistedDdValues, + attrName, + getValue( + isInline && + isExpressionInScope( + exprPath, + getHoistTargetPath(path) + ) + ) + ); + + // `useMemo` factories run during render at the hook call, so component + // bindings declared after it are still in their temporal dead zone + pushDdValue( + t, + factoryDdValues, + attrName, + getValue( + isInline && + isExpressionInScope( + exprPath, + getHoistTargetPath(path), + useMemoStart + ) + ) + ); } return; @@ -616,7 +821,111 @@ export function getJSXElementActionPaths( } }); - return { actionPathList, actionPathNames, ddValues }; + return { + actionPathList, + actionPathNames, + ddValues, + hoistedDdValues, + factoryDdValues + }; +} + +/** + * Returns the path whose scope matches where memoized handlers get their args object + * hoisted to: the nearest enclosing component (same lookup as `handleMemoization`), + * or the Program when there is none. + * + * @param path JSXElement path. + */ +function getHoistTargetPath( + path: Babel.NodePath +): Babel.NodePath { + return ( + path.findParent( + p => + p.isFunctionDeclaration() || + p.isFunctionExpression() || + p.isClassDeclaration() || + p.isProgram() + ) || path + ); +} + +/** + * Returns the earliest source position of a `useMemo` call whose result is used as + * one of this element's tracked handlers (e.g., `onPress={handler}` with + * `const handler = useMemo(...)`), or undefined when there is none. + * + * @param t Babel types helper. + * @param path JSXElement path. + * @param state Plugin state. + * @param componentName Host component name to look up handlers. + */ +function getUseMemoStart( + t: typeof Babel.types, + path: Babel.NodePath, + state: PluginPassState, + componentName: string +): number | undefined { + const handlerNames = + state.trackedComponents?.[componentName]?.handlers.map(x => x.event) || + []; + + let start: number | undefined; + for (const attr of path.node.openingElement.attributes) { + if ( + !t.isJSXAttribute(attr) || + !handlerNames.includes(getNodeName(t, attr.name) || '') || + !t.isJSXExpressionContainer(attr.value) || + !t.isIdentifier(attr.value.expression) + ) { + continue; + } + + const declarator = path.scope.getBinding(attr.value.expression.name) + ?.path.node; + if ( + !t.isVariableDeclarator(declarator) || + !t.isCallExpression(declarator.init) + ) { + continue; + } + + const callee = declarator.init.callee; + const hookName = t.isMemberExpression(callee) + ? getNodeName(t, callee.property) + : getNodeName(t, callee); + + if (hookName === 'useMemo' && declarator.init.start != null) { + start = Math.min(start ?? Infinity, declarator.init.start); + } + } + + return start; +} + +/** + * Appends a value to the array stored under `attrName`, creating it if needed. + * + * @param t Babel types helper. + * @param values Map of computed values (`ddValues` or `hoistedDdValues`). + * @param attrName Attribute name used as key. + * @param value Expression to append. + */ +function pushDdValue( + t: typeof Babel.types, + values: DdValues, + attrName: string, + value: Babel.types.Expression +) { + if (!values[attrName]) { + values[attrName] = t.arrayExpression([]); + } + + const valuesArray = values[attrName]; + if (t.isArrayExpression(valuesArray)) { + valuesArray.elements.push(value); + } } /** 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 1aa7e5665..5dcf8a8fd 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/tap.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/tap.ts @@ -10,6 +10,11 @@ import { RumAction, RumActionConstants } from '../../constants'; import type { PluginPassState, RumActionResult } from '../../types'; import { getArgumentsFromParams, getNodeName, toExpression } from '../../utils'; +type MemoArgsObjects = Record< + 'useCallback' | 'useMemo', + Babel.types.ObjectExpression +>; + /** * Wraps a tap handler attribute (e.g., onPress) with RUM Action Tracking wrapper call. * @@ -70,20 +75,39 @@ export function handleTapAction( (path.node.extra.ddValues as any)['handlerArgs'] = handlerArgs; } - const argsObject = t.objectExpression([ - ...Object.entries(path.node?.extra?.ddValues || {}).map( - ([key, value]) => { + if (path.node?.extra?.hoistedDdValues) { + (path.node.extra.hoistedDdValues as any)['handlerArgs'] = handlerArgs; + } + + if (path.node?.extra?.factoryDdValues) { + (path.node.extra.factoryDdValues as any)['handlerArgs'] = handlerArgs; + } + + const getArgsObject = (ddValues: unknown) => + t.objectExpression([ + ...Object.entries(ddValues || {}).map(([key, value]) => { return t.objectProperty( t.stringLiteral(key), toExpression(t, value) ); - } - ), - t.objectProperty( - t.stringLiteral('componentName'), - t.stringLiteral(parentName) - ) - ]); + }), + t.objectProperty( + t.stringLiteral('componentName'), + t.stringLiteral(parentName) + ) + ]); + + const argsObject = getArgsObject(path.node?.extra?.ddValues); + + // Memoized handlers are wrapped in the component body, outside the JSX scope + const hoistedArgsObjects: MemoArgsObjects = { + useCallback: path.node?.extra?.hoistedDdValues + ? getArgsObject(path.node.extra.hoistedDdValues) + : argsObject, + useMemo: path.node?.extra?.factoryDdValues + ? getArgsObject(path.node.extra.factoryDdValues) + : argsObject + }; // Used only for function references // Wraps our custom logic at the level of the memoization call insead of on the property level (default behaviour) @@ -99,7 +123,7 @@ export function handleTapAction( expression, fName, fNode, - argsObject, + hoistedArgsObjects, handler?.mode ); @@ -243,7 +267,7 @@ function getNamedFunctionNode( * @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 argsObjects - Datadog-specific args objects (ddValues/options), one per memoization hook. * @param mode - Optional handler mode; when `'delayed'`, wrapper invocation is deferred. * @returns `true` if considered memoized (wrapped or intentionally skipped), else `false`. */ @@ -257,7 +281,7 @@ function handleMemoization( | babel.types.FunctionDeclaration | babel.types.VariableDeclarator | null, - argsObject: babel.types.ObjectExpression, + argsObjects: MemoArgsObjects, mode?: string ) { if (!fName || !fNode || t.isFunctionDeclaration(fNode)) { @@ -280,10 +304,12 @@ function handleMemoization( memoName = getNodeName(t, varInit.callee.property); } - if (!memoName || !['useCallback', 'useMemo'].includes(memoName)) { + if (memoName !== 'useCallback' && memoName !== 'useMemo') { return false; } + const argsObject = argsObjects[memoName]; + const callbackFn = varInit.arguments.at(0); const handleWrapping = ( callback: diff --git a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts index 4ff102214..db4f898b4 100644 --- a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts +++ b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts @@ -603,3 +603,82 @@ export function findIdentifierInScope( return foundValue; } + +/** + * Checks whether an expression can be safely evaluated at a different location + * than where it was written, i.e. whether every free binding it references resolves + * to the same binding from `targetPath`'s scope. Globals are always visible, and + * bindings declared within the expression (e.g., callback params) travel with it. + * + * When `initializedBefore` is given, the expression is evaluated eagerly at that source + * position, so non-program bindings must also be initialized there: params and function + * declarations always are, other declarations only if they end before that position. + * + * @param exprPath Path of the expression to check. + * @param targetPath Path where the expression would be evaluated. + * @param initializedBefore Optional source position where the expression is evaluated eagerly. + * @returns `true` if all referenced bindings are visible (and initialized) from the target location. + */ +export function isExpressionInScope( + exprPath: Babel.NodePath, + targetPath: Babel.NodePath, + initializedBefore?: number +): boolean { + let inScope = true; + + const check = ( + path: Babel.NodePath + ) => { + if (!inScope || !path.isReferencedIdentifier()) { + return; + } + + const name = path.node.name; + const binding = path.scope.getBinding(name); + + if (!binding || binding.path.isDescendant(exprPath)) { + return; + } + + if (targetPath.scope.getBinding(name) !== binding) { + inScope = false; + return; + } + + if ( + initializedBefore === undefined || + binding.scope.path.isProgram() || + binding.kind === 'param' || + binding.kind === 'hoisted' + ) { + return; + } + + const declarationEnd = binding.path.node.end; + if (declarationEnd == null || declarationEnd > initializedBefore) { + inScope = false; + } + }; + + if (exprPath.isIdentifier() || exprPath.isJSXIdentifier()) { + check(exprPath); + return inScope; + } + + exprPath.traverse({ + Identifier(path) { + check(path); + if (!inScope) { + path.stop(); + } + }, + JSXIdentifier(path) { + check(path); + if (!inScope) { + path.stop(); + } + } + }); + + return inScope; +} diff --git a/packages/react-native-babel-plugin/test/actionNameAttribute.test.ts b/packages/react-native-babel-plugin/test/actionNameAttribute.test.ts new file mode 100644 index 000000000..382213112 --- /dev/null +++ b/packages/react-native-babel-plugin/test/actionNameAttribute.test.ts @@ -0,0 +1,497 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { transform } from '@babel/core'; + +import plugin from '../src/index'; +import type { PluginOptions } from '../src/types'; + +// Covers how DD attribute values (`dd-action-name`, `accessibilityLabel` and the custom +// `actionNameAttribute`) reach the runtime depending on the JSX attribute value shape. + +function transformCode(code: string) { + const options: PluginOptions = { + actionNameAttribute: 'testID', + components: { + useContent: true, + useNamePrefix: false, + tracked: [ + { + name: 'CustomButton', + handlers: [{ event: 'onPress', action: 'TAP' }] + } + ] + }, + sessionReplay: { svgTracking: false } + } as PluginOptions; + + return transform(code, { + filename: 'file.tsx', + presets: [ + ['@babel/preset-react', { runtime: 'classic' }], + '@babel/preset-typescript' + ], + plugins: [ + [plugin, options], + '@babel/plugin-transform-modules-commonjs' + ], + configFile: false + })?.code as string; +} + +/** + * Transforms `code`, executes it with stubbed modules, presses every rendered + * element and returns the `targetObject` each press handed to `wrapRumAction`. + * Like the runtime, `getContent` is resolved when the wrapper runs; errors it + * throws are collected in `contentErrors`. + */ +function pressAll(code: string, modules: Record = {}) { + const output = transformCode(code); + const captured: Record[] = []; + const rendered: Record[] = []; + const contentErrors: unknown[] = []; + let isResolvingContent = false; + + const DdBabelInteractionTracking = { + getInstance: () => DdBabelInteractionTracking, + wrapRumAction: ( + fn: (...a: any[]) => any, + _type: string, + target: Record + ) => (...args: any[]) => { + captured.push(target); + isResolvingContent = true; + try { + target.getContent?.(); + } catch (e) { + contentErrors.push(e); + } finally { + isResolvingContent = false; + } + return fn?.(...args); + } + }; + + const React = { + createElement: (_type: any, props: any) => { + if (!isResolvingContent) { + rendered.push(props || {}); + } + return { props }; + }, + Fragment: 'Fragment', + useCallback: (fn: any) => fn, + // Like React, run the factory during render + useMemo: (factory: () => any) => factory() + }; + + const allModules: Record = { + react: React, + 'react/jsx-runtime': { + jsx: React.createElement, + jsxs: React.createElement, + Fragment: 'Fragment' + }, + 'react-native': { + Button: 'Button', + Pressable: 'Pressable', + Text: 'Text' + }, + '@datadog/mobile-react-native': { + DdBabelInteractionTracking, + __ddExtractText: () => [] + }, + './CustomButton': { CustomButton: 'CustomButton' }, + ...modules + }; + + const exportsObj: Record = {}; + // eslint-disable-next-line no-new-func + new Function('require', 'exports', 'React', output)( + (name: string) => allModules[name], + exportsObj, + React + ); + if (typeof exportsObj.render === 'function') { + exportsObj.render(); + } + + for (const props of rendered) { + props.onPress?.(); + } + + return { output, captured, contentErrors }; +} + +describe('Babel plugin: DD attribute values', () => { + const testIDs = { './testIDs': { TestIDs: { someTestId: 'imported_id' } } }; + + it('forwards string literal attributes', () => { + const { captured } = pressAll(` + import { Button } from 'react-native'; +