diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts index 5a0833342..124ba87e9 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts @@ -155,7 +155,7 @@ class BabelInteractionTracking { }); } - return func(...args); + return func?.(...args); }; } } diff --git a/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts new file mode 100644 index 000000000..a5ffa9d32 --- /dev/null +++ b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/DdBabelInteractionTracking.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { RumActionType } from '../../../types'; +import { DdBabelInteractionTracking } from '../DdBabelInteractionTracking'; + +jest.mock('../../../../specs/NativeDdSdk', () => ({ + __esModule: true, + default: { + sendTelemetryLog: jest.fn(), + telemetryError: jest.fn() + } +})); + +jest.mock('../../../../utils/time-provider/DefaultTimeProvider', () => ({ + DefaultTimeProvider: jest.fn().mockImplementation(() => ({ + now: jest.fn().mockReturnValue(456) + })) +})); + +const mockTargetObject = { + getContent: undefined, + options: { useContent: true, useNamePrefix: true }, + handlerArgs: [], + componentName: 'Button', + 'dd-action-name': [], + accessibilityLabel: [] +}; + +describe('DdBabelInteractionTracking.wrapRumAction', () => { + it('should not crash when func is undefined', () => { + const wrapped = DdBabelInteractionTracking.wrapRumAction( + undefined as any, + RumActionType.TAP, + mockTargetObject + ); + + expect(() => wrapped()).not.toThrow(); + expect(wrapped()).toBeUndefined(); + }); + + it('should not crash when func is null', () => { + const wrapped = DdBabelInteractionTracking.wrapRumAction( + null as any, + RumActionType.TAP, + mockTargetObject + ); + + expect(() => wrapped()).not.toThrow(); + expect(wrapped()).toBeUndefined(); + }); + + it('should call func when it is defined', () => { + const func = jest.fn().mockReturnValue('result'); + const wrapped = DdBabelInteractionTracking.wrapRumAction( + func, + RumActionType.TAP, + mockTargetObject + ); + + const result = wrapped('arg1', 'arg2'); + + expect(func).toHaveBeenCalledWith('arg1', 'arg2'); + expect(result).toBe('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 1a2224455..6e609ea92 100644 --- a/packages/react-native-babel-plugin/src/actions/rum/tap.ts +++ b/packages/react-native-babel-plugin/src/actions/rum/tap.ts @@ -113,9 +113,11 @@ export function handleTapAction( expression, getArgumentsFromParams(t, state, expression.params).callArgs ) - : t.callExpression(expression, [ - t.spreadElement(t.identifier('args')) - ]); + : t.optionalCallExpression( + expression, + [t.spreadElement(t.identifier('args'))], + true + ); } state.hasValidTapAction = true; @@ -296,7 +298,7 @@ function handleMemoization( const returnExpression = mode === 'delayed' ? callback - : t.callExpression(callback, callArgs); + : t.optionalCallExpression(callback, callArgs, true); // Update handlerArgs in argsObject to match the actual callback params // instead of the default [...args] computed from the outer identifier diff --git a/packages/react-native-babel-plugin/test/plugin.test.ts b/packages/react-native-babel-plugin/test/plugin.test.ts index 2a1ca9575..ff567464e 100644 --- a/packages/react-native-babel-plugin/test/plugin.test.ts +++ b/packages/react-native-babel-plugin/test/plugin.test.ts @@ -91,7 +91,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Button" - })(...args);else return func(...args); + })(...args);else return func?.(...args); } });" `); @@ -557,7 +557,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return func(...args); + })(...args);else return func?.(...args); } });" `); @@ -592,7 +592,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return func3(...args); + })(...args);else return func3?.(...args); } });" `); @@ -626,7 +626,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return a(...args); + })(...args);else return a?.(...args); } });" `); @@ -827,7 +827,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return handler(...args); + })(...args);else return handler?.(...args); } }); }" @@ -864,7 +864,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return handler(...args); + })(...args);else return handler?.(...args); } }); }" @@ -901,7 +901,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return globalThis.handler(...args); + })(...args);else return globalThis.handler?.(...args); } }); }" @@ -942,7 +942,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return props.onPress(...args); + })(...args);else return props.onPress?.(...args); } })); }" @@ -986,7 +986,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Pressable" - })(...args);else return onPress(...args); + })(...args);else return onPress?.(...args); } })); }" @@ -1143,7 +1143,7 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Button" - })(...args);else return this.handlePress(...args); + })(...args);else return this.handlePress?.(...args); } })); } @@ -1185,13 +1185,49 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => { }, "handlerArgs": [...args], "componentName": "Button" - })(...args);else return func(...args); + })(...args);else return func?.(...args); } });" `); }); }); +describe('Babel plugin: optional handler guard (undefined handler safety)', () => { + it('should generate optional chaining in fallback branch for named function reference that may be undefined', () => { + const input = ` + import { Pressable, Text, TextInput as RNTextInput } from 'react-native'; + + function TextInput({ label, onPress }: { label: string; onPress?: () => void }) { + return ( + + {label} + + + ); + } + `; + const output = transformCode(input); + // The branch when the SDK is not initialized must use optional chaining + expect(output).toContain('onPress?.(...args)'); + // The branch when the SDK is initialized should pass the handler to wrapRumAction + expect(output).toContain('wrapRumAction(onPress,'); + }); + + it('should generate optional chaining in fallback branch for Pressable with arrow function handler', () => { + const input = ` + import { Pressable, Text } from 'react-native'; + { console.log('pressed'); }}> + Click me + + `; + const output = transformCode(input); + // Arrow functions are always defined so optional chaining is acceptable but not strictly required + // The key assertion is that the output compiles without crashing + expect(output).toBeDefined(); + expect(output).toContain('DdBabelInteractionTracking'); + }); +}); + describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', () => { it('should wrap arrow function inside `useCallback` with parameter (gesture/native event)', () => { const input = ` @@ -1237,7 +1273,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', console.log('Testing ', a, b, event); setA(x => x + 1); setB(x => x + 1); - })(event); + })?.(event); }, [a, b]); return /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1335,7 +1371,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', "componentName": "Pressable" })();else return (() => { console.log('Testing '); - })(); + })?.(); }, []); return /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1385,7 +1421,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', "componentName": "Pressable" })();else return (() => { console.log('Testing '); - })(); + })?.(); }, []); return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1443,7 +1479,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }, "handlerArgs": [test], "componentName": "Pressable" - })(test);else return funcN(test); + })(test);else return funcN?.(test); }, [a, b]); return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1498,7 +1534,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }, "handlerArgs": [test], "componentName": "Pressable" - })(test);else return funcN(test); + })(test);else return funcN?.(test); }, [a, b]); return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1549,7 +1585,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }, "handlerArgs": [test], "componentName": "Pressable" - })(test);else return funcN(test); + })(test);else return funcN?.(test); }, [a, b]); return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { color: "red", @@ -1634,7 +1670,7 @@ describe('Babel plugin: wrap interaction handlers for RUM ( with memoization )', }, "handlerArgs": [], "componentName": "Pressable" - })();else return funcN(); + })();else return funcN?.(); }, []); return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, { color: "red",