Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <Pressable onPress={onPress} onLongPress={onLongPress} />;
}
`,
{
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<string, any> = {
react: React,
'react-native': ReactNative,
'@datadog/mobile-react-native': {
DdBabelInteractionTracking: tracking
}
};
const exports: Record<string, any> = {};
// 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();
}
);
}
);
44 changes: 35 additions & 9 deletions packages/react-native-babel-plugin/src/actions/rum/tap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
75 changes: 65 additions & 10 deletions packages/react-native-babel-plugin/test/handlerShadowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CustomButton onPress={${reference}} />;
}
`,
{ 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 <Button onPress={handler} />;
const props = {
get onPress() {
reads++;
return (...args) => { calls.push(args); return args; };
}
};
return <Button onPress={props.onPress} />;
}
`,
{ 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 <Button onPress={handler} />; }
`,
{ trackingAvailable }
);
fixture.exports.changeHandler();
expect(fixture.handler()).toBe('original');
}
);

Expand Down
Loading
Loading