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 f5ec2c700..b7eab2bce 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/index.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/index.ts @@ -92,14 +92,25 @@ export function handleJSXElementActionPaths( ? Object.keys(state.trackedComponents) : []; - // Some components need specific handlers present (inject no-op handlers if missing) - ensureMandatoryAttributes( - path, - componentName, - actionPathList, - actionPathNames + // Only inject mandatory attributes for native components, NOT options tracked components + // Options tracked components define their own handlers and should not have additional ones injected + const isOptionsTrackedComponent = options.components.tracked.find( + x => x.name === componentName ); + if ( + !isOptionsTrackedComponent && + componentNameList.includes(componentName) + ) { + // Some native components need specific handlers present (inject no-op handlers if missing) + ensureMandatoryAttributes( + path, + componentName, + actionPathList, + actionPathNames + ); + } + // Optionally compute a content getter (children + label props) setContentAttribute(componentName, t, path, state, ddValues); @@ -115,13 +126,17 @@ export function handleJSXElementActionPaths( /** * 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. + * 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. * + * IMPORTANT: If the element has spread attributes (e.g., {...props}), we cannot + * safely inject handlers because we don't know at build time what props are being + * spread. In such cases, we skip injection to avoid overwriting existing handlers. + * * @param path JSXElement path. * @param componentName Host component name for lookup in `tapElementsRequiredAttributesMap`. * @param actionPathList Collected actionable attribute paths (will be appended to). @@ -133,6 +148,17 @@ export function ensureMandatoryAttributes( actionPathList: Babel.NodePath[], actionPathNames: string[] ) { + // Check if there are any spread attributes + const hasSpreadAttributes = path.node.openingElement.attributes.some( + attr => attr.type === 'JSXSpreadAttribute' + ); + + // If spread attributes exist, we cannot safely inject handlers + // because we don't know what props are being spread at build time + if (hasSpreadAttributes) { + return; + } + // Resolve any mandatory attributes for this component const requiredAttributes = tapElementsRequiredAttributesMap[componentName]; if (requiredAttributes) { diff --git a/packages/react-native-babel-plugin/test/plugin.test.ts b/packages/react-native-babel-plugin/test/plugin.test.ts index 7e02d676b..ad861b493 100644 --- a/packages/react-native-babel-plugin/test/plugin.test.ts +++ b/packages/react-native-babel-plugin/test/plugin.test.ts @@ -1,18 +1,43 @@ /* - * 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/). + * 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'; + +function transformCode(code: string, pluginOptions?: Partial) { + const defaultOptions: PluginOptions = { + actionNameAttribute: 'example-button-prop', + components: { + useContent: true, + useNamePrefix: true, + tracked: [] + }, + sessionReplay: { + svgTracking: false + } + }; + + const options = { + ...defaultOptions, + ...pluginOptions, + components: { + ...defaultOptions.components, + ...pluginOptions?.components + }, + sessionReplay: { + ...defaultOptions.sessionReplay, + ...pluginOptions?.sessionReplay + } + }; -function transformCode(code: string) { return transform(code, { filename: 'file.tsx', presets: ['@babel/preset-react', '@babel/preset-typescript'], - plugins: [[plugin, { actionNameAttribute: 'example-button-prop' }]], + plugins: [[plugin, options]], configFile: false })?.code; } @@ -147,6 +172,280 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { `); }); + it('should not add mandatory property (onFocus) on supported element (TextInput) when not present if there`s options tracked component with the same name', () => { + const options: Partial = { + components: { + useContent: true, + useNamePrefix: true, + tracked: [ + { + name: 'TextInput', + handlers: [{ event: 'onFocus', action: 'TAP' }] + } + ] + } + }; + + const input = ` + import { TextInput } from 'react-native'; + + `; + const output = transformCode(input, options); + expect(output).toMatchInlineSnapshot(` + "import { TextInput } from 'react-native'; + /*#__PURE__*/React.createElement(TextInput, { + placeholder: "Enter username", + value: username, + onChangeText: setUsername, + style: styles.input + });" + `); + }); + + it('should not add property (onFocus) on supported element (TextInput) when spreading props', () => { + const input = ` + import { TextInput } from 'react-native'; + + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "import { TextInput } from 'react-native'; + /*#__PURE__*/React.createElement(TextInput, props);" + `); + }); + + it('should wrap existing (onFocus) on supported element (TextInput) when spreading props', () => { + const input = ` + import { TextInput } from 'react-native'; + console.log('Focused')}/> + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; + function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import { TextInput } from 'react-native'; + /*#__PURE__*/React.createElement(TextInput, _extends({}, props, { + onFocus: () => { + if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => console.log('Focused'), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], + "componentName": "TextInput" + })();else return (() => console.log('Focused'))(); + } + }));" + `); + }); + + it('should not add property (onFocus) on custom element (TextInput) when not present', () => { + const input = ` + import { TextInput } from './TextInput'; + + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, null);" + `); + }); + + it('should not add property (onFocus) on custom element (TextInput - not tracked) when spreading props', () => { + const input = ` + import { TextInput } from './TextInput'; + + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, props);" + `); + }); + + it('should not add property (onFocus) on custom element (TextInput - tracked) when spreading props', () => { + const options: Partial = { + components: { + useContent: true, + useNamePrefix: true, + tracked: [ + { + name: 'TextInput', + handlers: [{ event: 'onFocus', action: 'TAP' }] + } + ] + } + }; + const input = ` + import { TextInput } from './TextInput'; + + `; + const output = transformCode(input, options); + expect(output).toMatchInlineSnapshot(` + "import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, props);" + `); + }); + + it('should not wrap existing (onFocus) on custom element (TextInput - not tracked) when spreading props', () => { + // Since it's a custom not-tracked component, the plugin doesn't know if this handler should be wraaped or not + const input = ` + import { TextInput } from './TextInput'; + console.log('Focused')}/> + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, _extends({}, props, { + onFocus: () => console.log('Focused') + }));" + `); + }); + + it('should wrap existing (onFocus) on custom element (TextInput - tracked) when spreading props', () => { + // Since it's a custom tracked component, the plugin knows which handler to track + const options: Partial = { + components: { + useContent: true, + useNamePrefix: true, + tracked: [ + { + name: 'TextInput', + handlers: [{ event: 'onFocus', action: 'TAP' }] + } + ] + } + }; + + const input = ` + import { TextInput } from './TextInput'; + console.log('Focused')}/> + `; + + const output = transformCode(input, options); + expect(output).toMatchInlineSnapshot(` + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; + function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, _extends({}, props, { + onFocus: () => { + if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => console.log('Focused'), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], + "componentName": "TextInput" + })();else return (() => console.log('Focused'))(); + } + }));" + `); + }); + + it('should wrap arrow function with one argument', () => { + const input = ` + import { Pressable } from 'react-native'; + { + console.log('Testing: ', event); + }} /> + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "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); + })(event); + } + });" + `); + }); + + it('should not wrap existing (onFocus) on custom element (TextInput - tracked) when spreading props', () => { + // Since it's a custom not-tracked component, the plugin doesn't know if this handler should be wraaped or not + const input = ` + import { TextInput } from './TextInput'; + console.log('Focused')}/> + `; + const output = transformCode(input); + expect(output).toMatchInlineSnapshot(` + "function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, _extends({}, props, { + onFocus: () => console.log('Focused') + }));" + `); + }); + + it('should wrap existing (onFocus) on custom element (TextInput - tracked) when spreading props', () => { + // Since it's a custom tracked component, the plugin knows which handler to track + const options: Partial = { + components: { + useContent: true, + useNamePrefix: true, + tracked: [ + { + name: 'TextInput', + handlers: [{ event: 'onFocus', action: 'TAP' }] + } + ] + } + }; + + const input = ` + import { TextInput } from './TextInput'; + console.log('Focused')}/> + `; + + const output = transformCode(input, options); + expect(output).toMatchInlineSnapshot(` + "import { DdBabelInteractionTracking, __ddExtractText } from "@datadog/mobile-react-native"; + function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } + import { TextInput } from './TextInput'; + /*#__PURE__*/React.createElement(TextInput, _extends({}, props, { + onFocus: () => { + if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(() => console.log('Focused'), "TAP", { + "options": { + "useContent": true, + "useNamePrefix": true + }, + "getContent": () => { + return __ddExtractText(/*#__PURE__*/React.createElement(React.Fragment, null), []); + }, + "handlerArgs": [], + "componentName": "TextInput" + })();else return (() => console.log('Focused'))(); + } + }));" + `); + }); + it('should wrap arrow function with one argument', () => { const input = ` import { Pressable } from 'react-native';