diff --git a/packages/core/src/rum/instrumentation/interactionTracking/__tests__/BabelOptionalHandlers.test.ts b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/BabelOptionalHandlers.test.ts
new file mode 100644
index 000000000..81c6926c2
--- /dev/null
+++ b/packages/core/src/rum/instrumentation/interactionTracking/__tests__/BabelOptionalHandlers.test.ts
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+// Babel is a development dependency of the workspace root.
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { transformSync } from '@babel/core';
+import { createRequire } from 'module';
+import Pressability from 'react-native/Libraries/Pressability/Pressability';
+import * as ReactNative from 'react-native';
+import * as React from 'react';
+
+import plugin from '../../../../../../react-native-babel-plugin/src';
+
+const nodeRequire = createRequire(__filename);
+
+function loadButtonFixture(trackingAvailable: boolean) {
+ const trackAction = jest.fn();
+ const tracking = {
+ getInstance: () => (trackingAvailable ? tracking : null),
+ wrapRumAction: (handler: (...args: any[]) => any) => (
+ ...args: any[]
+ ) => {
+ trackAction();
+ return handler?.(...args);
+ }
+ };
+ // Reproduce #1509 independently of the example app: optional handlers must
+ // remain absent after transforming a component with Metro's preset.
+ const output = transformSync(
+ `
+ import React from 'react';
+ import { Pressable } from 'react-native';
+
+ export function OptionalHandlerButton({ onPress, onLongPress }) {
+ return ;
+ }
+ `,
+ {
+ filename: 'optional-handlers.tsx',
+ configFile: false,
+ babelrc: false,
+ presets: ['module:@react-native/babel-preset'],
+ plugins: [[plugin, { sessionReplay: { svgTracking: false } }]]
+ }
+ )?.code as string;
+ const modules: Record = {
+ react: React,
+ 'react-native': ReactNative,
+ '@datadog/mobile-react-native': {
+ DdBabelInteractionTracking: tracking
+ }
+ };
+ const exports: Record = {};
+ // eslint-disable-next-line no-new-func
+ new Function('require', 'exports', output)(
+ (id: string) => modules[id] ?? nodeRequire(id),
+ exports
+ );
+ return { Button: exports.OptionalHandlerButton, trackAction };
+}
+
+describe.each([true, false])(
+ 'issue #1509 with tracking available=%s',
+ trackingAvailable => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it.each([undefined, null, 'defined'])(
+ 'preserves short and long presses with onLongPress=%s',
+ value => {
+ const { Button, trackAction } = loadButtonFixture(
+ trackingAvailable
+ );
+ const onPress = jest.fn();
+ const onLongPress = value === 'defined' ? jest.fn() : value;
+ const element = Button({ onPress, onLongPress });
+ // Exercise React Native's actual state machine, including its timer
+ // and the onLongPress-presence check that suppresses onPress.
+ const pressability = new Pressability(element.props);
+ const handlers = pressability.getEventHandlers();
+ const event = {
+ currentTarget: 1,
+ nativeEvent: { pageX: 0, pageY: 0 },
+ persist: jest.fn()
+ } as any;
+
+ handlers.onResponderGrant(event);
+ jest.advanceTimersByTime(100);
+ handlers.onResponderRelease(event);
+ expect(onPress).toHaveBeenCalledTimes(1);
+ if (typeof onLongPress === 'function') {
+ expect(onLongPress).not.toHaveBeenCalled();
+ }
+
+ handlers.onResponderGrant(event);
+ jest.advanceTimersByTime(600);
+ handlers.onResponderRelease(event);
+ expect(onPress).toHaveBeenCalledTimes(
+ onLongPress == null ? 2 : 1
+ );
+ if (onLongPress == null) {
+ expect(element.props.onLongPress).toBe(onLongPress);
+ }
+ if (typeof onLongPress === 'function') {
+ expect(onLongPress).toHaveBeenCalledTimes(1);
+ }
+ expect(trackAction).toHaveBeenCalledTimes(
+ trackingAvailable ? 2 : 0
+ );
+ pressability.reset();
+ }
+ );
+ }
+);
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 b68360ebf..d703a9752 100644
--- a/packages/react-native-babel-plugin/src/actions/rum/tap.ts
+++ b/packages/react-native-babel-plugin/src/actions/rum/tap.ts
@@ -352,16 +352,42 @@ function getActionWrapperNode(
argsObject: Babel.types.ObjectExpression,
mode?: string
) {
+ const isReference =
+ t.isIdentifier(expression) || t.isMemberExpression(expression);
+ const handler = isReference
+ ? path.scope.generateUidIdentifier('ddHandler')
+ : expression;
+ const wrapper = getActionWrapperFunction(
+ t,
+ path,
+ state,
+ handler,
+ params,
+ argsObject,
+ mode
+ );
+
+ // Components can change behavior based on a handler's presence (e.g.
+ // Pressable suppresses onPress when onLongPress is set). Preserve nullish
+ // references and evaluate the original prop only once, including getters.
return t.jsxExpressionContainer(
- getActionWrapperFunction(
- t,
- path,
- state,
- expression,
- params,
- argsObject,
- mode
- )
+ isReference && t.isIdentifier(handler)
+ ? t.callExpression(
+ t.arrowFunctionExpression(
+ [t.cloneNode(handler)],
+ t.conditionalExpression(
+ t.binaryExpression(
+ '==',
+ t.cloneNode(handler),
+ t.nullLiteral()
+ ),
+ t.cloneNode(handler),
+ wrapper
+ )
+ ),
+ [expression]
+ )
+ : wrapper
);
}
diff --git a/packages/react-native-babel-plugin/test/handlerShadowing.test.ts b/packages/react-native-babel-plugin/test/handlerShadowing.test.ts
index fe1972345..c06e47bfd 100644
--- a/packages/react-native-babel-plugin/test/handlerShadowing.test.ts
+++ b/packages/react-native-babel-plugin/test/handlerShadowing.test.ts
@@ -194,23 +194,78 @@ describe('generated handler bindings', () => {
}
);
- it.each(['null', 'undefined'])(
- 'keeps an optional %s handler safe when tracking is unavailable',
- value => {
+ describe.each([true, false])(
+ 'optional handlers with tracking available=%s',
+ trackingAvailable => {
+ it.each(['null', 'undefined'])(
+ 'preserves %s for identifiers and members in both modes',
+ value => {
+ for (const mode of ['default', 'delayed'] as const) {
+ for (const reference of ['handler', 'props.onPress']) {
+ const fixture = renderHandler(
+ `
+ import { CustomButton } from './CustomButton';
+ export function render() {
+ const handler = ${value};
+ const props = { get onPress() { return handler; } };
+ return ;
+ }
+ `,
+ { trackingAvailable, mode }
+ );
+ expect(fixture.handler).toBe(
+ value === 'null' ? null : undefined
+ );
+ expect(fixture.captured).toEqual([]);
+ }
+ }
+ }
+ );
+ }
+ );
+
+ it.each([true, false])(
+ 'evaluates a member handler once with tracking available=%s',
+ trackingAvailable => {
const fixture = renderHandler(
`
import { Button } from 'react-native';
+ export let reads = 0;
+ export const calls = [];
export function render() {
- const handler = ${value};
- return ;
+ const props = {
+ get onPress() {
+ reads++;
+ return (...args) => { calls.push(args); return args; };
+ }
+ };
+ return ;
}
`,
- { trackingAvailable: false }
+ { trackingAvailable }
);
- expect(
- fixture.handler({ nativeEvent: { target: 1 } })
- ).toBeUndefined();
- expect(fixture.captured).toEqual([]);
+ expect(fixture.exports.reads).toBe(1);
+ expect(fixture.handler('first', 2)).toEqual(['first', 2]);
+ expect(fixture.exports.reads).toBe(1);
+ expect(fixture.exports.calls).toEqual([['first', 2]]);
+ expect(fixture.captured).toHaveLength(trackingAvailable ? 1 : 0);
+ }
+ );
+
+ it.each([true, false])(
+ 'captures the handler value at render with tracking available=%s',
+ trackingAvailable => {
+ const fixture = renderHandler(
+ `
+ import { Button } from 'react-native';
+ let handler = () => 'original';
+ export function changeHandler() { handler = () => 'replacement'; }
+ export function render() { return ; }
+ `,
+ { trackingAvailable }
+ );
+ fixture.exports.changeHandler();
+ expect(fixture.handler()).toBe('original');
}
);
diff --git a/packages/react-native-babel-plugin/test/plugin.test.ts b/packages/react-native-babel-plugin/test/plugin.test.ts
index f38915c1a..ad5d7f5bf 100644
--- a/packages/react-native-babel-plugin/test/plugin.test.ts
+++ b/packages/react-native-babel-plugin/test/plugin.test.ts
@@ -201,8 +201,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
import { Button } from 'react-native';
/*#__PURE__*/React.createElement(Button, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -212,8 +212,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Button",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return func?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(func)
});"
`);
});
@@ -667,8 +667,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
};
/*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -678,8 +678,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return func?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(func)
});"
`);
});
@@ -702,8 +702,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
}
/*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func3, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -713,8 +713,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return func3?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(func3)
});"
`);
});
@@ -736,8 +736,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
console.log(event, data, rest);
}
/*#__PURE__*/React.createElement(Pressable, {
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(a, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -747,8 +747,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return a?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(a)
});"
`);
});
@@ -927,8 +927,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
function MyComponent() {
return /*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(handler, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -938,8 +938,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return handler?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(handler)
});
}"
`);
@@ -964,8 +964,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
function MyComponent() {
return /*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(handler, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -975,8 +975,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return handler?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(handler)
});
}"
`);
@@ -1001,8 +1001,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
function MyComponent() {
return /*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(globalThis.handler, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -1012,8 +1012,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return globalThis.handler?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(globalThis.handler)
});
}"
`);
@@ -1042,8 +1042,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
function MyComponent(props) {
return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(props.onPress, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -1053,8 +1053,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return props.onPress?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(props.onPress)
}));
}"
`);
@@ -1086,8 +1086,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
}) {
return /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Pressable, {
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(onPress, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -1097,8 +1097,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Pressable",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return onPress?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(onPress)
}));
}"
`);
@@ -1243,8 +1243,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
}
}, /*#__PURE__*/React.createElement(Button, {
title: "Press Me",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(this.handlePress, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -1254,8 +1254,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Button",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return this.handlePress?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(this.handlePress)
}));
}
}"
@@ -1282,8 +1282,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
"example-button-prop": "action-name-attr-button",
accessibilityLabel: "accessibility-action-button",
color: "red",
- onPress: (..._ddArg) => {
- if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(func, "TAP", {
+ onPress: (_ddHandler => _ddHandler == null ? _ddHandler : (..._ddArg) => {
+ if (DdBabelInteractionTracking.getInstance()) return DdBabelInteractionTracking.getInstance().wrapRumAction(_ddHandler, "TAP", {
"options": {
"useContent": true,
"useNamePrefix": true
@@ -1296,8 +1296,8 @@ describe('Babel plugin: wrap interaction handlers for RUM', () => {
},
"componentName": "Button",
"handlerArgs": [..._ddArg]
- })(..._ddArg);else return func?.(..._ddArg);
- }
+ })(..._ddArg);else return _ddHandler?.(..._ddArg);
+ })(func)
});"
`);
});
@@ -1319,9 +1319,9 @@ describe('Babel plugin: optional handler guard (undefined handler safety)', () =
`;
const output = transformCode(input);
// The branch when the SDK is not initialized must use optional chaining
- expect(output).toMatch(/onPress\?\.\(\.\.\.[\w$]+\)/);
+ expect(output).toMatch(/_ddHandler\?\.\(\.\.\.[\w$]+\)/);
// The branch when the SDK is initialized should pass the handler to wrapRumAction
- expect(output).toContain('wrapRumAction(onPress,');
+ expect(output).toContain('wrapRumAction(_ddHandler,');
});
it('should generate optional chaining in fallback branch for Pressable with arrow function handler', () => {