From 106c799381104e1e8ddabaa7a5026e2a77e7ca40 Mon Sep 17 00:00:00 2001 From: "carlos.nogueira" Date: Fri, 30 Oct 2026 14:50:43 +0000 Subject: [PATCH] Babel plugin: preserve optional handlers --- .../__tests__/BabelOptionalHandlers.test.ts | 120 ++++++++++++++++++ .../src/actions/rum/tap.ts | 44 +++++-- .../test/handlerShadowing.test.ts | 75 +++++++++-- .../test/plugin.test.ts | 92 +++++++------- 4 files changed, 266 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/interactionTracking/__tests__/BabelOptionalHandlers.test.ts 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