fix: RN Gesture Handler Pressable support (#1746)

This commit is contained in:
Maciej Jastrzebski
2025-02-25 23:22:16 +01:00
committed by GitHub
parent a9c056bd02
commit bd78a26b2a
10 changed files with 224 additions and 74 deletions
@@ -20,7 +20,7 @@ describe('AnimatedView', () => {
);
expect(screen.root).toHaveStyle({ opacity: 0 });
await act(() => jest.advanceTimersByTime(250));
act(() => jest.advanceTimersByTime(250));
expect(screen.root).toHaveStyle({ opacity: 1 });
});
@@ -32,7 +32,7 @@ describe('AnimatedView', () => {
);
expect(screen.root).toHaveStyle({ opacity: 0 });
await act(() => jest.advanceTimersByTime(250));
act(() => jest.advanceTimersByTime(250));
expect(screen.root).toHaveStyle({ opacity: 1 });
});
});
+1
View File
@@ -50,6 +50,7 @@
"chalk": "^4.1.2",
"jest-matcher-utils": "^29.7.0",
"pretty-format": "^29.7.0",
"react-native-gesture-handler": "^2.23.1",
"redent": "^3.0.0"
},
"peerDependencies": {
+4 -4
View File
@@ -43,7 +43,7 @@ describe('AnimatedView', () => {
jest.useRealTimers();
});
it('should use native driver when useNativeDriver is true', async () => {
it('should use native driver when useNativeDriver is true', () => {
render(
<AnimatedView fadeInDuration={250} useNativeDriver={true}>
Test
@@ -51,12 +51,12 @@ describe('AnimatedView', () => {
);
expect(screen.root).toHaveStyle({ opacity: 0 });
await act(() => jest.advanceTimersByTime(250));
act(() => jest.advanceTimersByTime(250));
// This stopped working in tests in RN 0.77
// expect(screen.root).toHaveStyle({ opacity: 0 });
});
it('should not use native driver when useNativeDriver is false', async () => {
it('should not use native driver when useNativeDriver is false', () => {
render(
<AnimatedView fadeInDuration={250} useNativeDriver={false}>
Test
@@ -64,7 +64,7 @@ describe('AnimatedView', () => {
);
expect(screen.root).toHaveStyle({ opacity: 0 });
await act(() => jest.advanceTimersByTime(250));
act(() => jest.advanceTimersByTime(250));
expect(screen.root).toHaveStyle({ opacity: 1 });
});
});
@@ -0,0 +1,61 @@
import 'react-native-gesture-handler/jestSetup.js';
import React from 'react';
import { View } from 'react-native';
import { Pressable } from 'react-native-gesture-handler';
import { fireEvent, render, screen, userEvent } from '..';
import { createEventLogger, getEventsNames } from '../test-utils';
test('fireEvent can invoke press events for RNGH Pressable', () => {
const onPress = jest.fn();
const onPressIn = jest.fn();
const onPressOut = jest.fn();
const onLongPress = jest.fn();
render(
<View>
<Pressable
testID="pressable"
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
onLongPress={onLongPress}
/>
</View>,
);
const pressable = screen.getByTestId('pressable');
fireEvent.press(pressable);
expect(onPress).toHaveBeenCalled();
fireEvent(pressable, 'pressIn');
expect(onPressIn).toHaveBeenCalled();
fireEvent(pressable, 'pressOut');
expect(onPressOut).toHaveBeenCalled();
fireEvent(pressable, 'longPress');
expect(onLongPress).toHaveBeenCalled();
});
test('userEvent can invoke press events for RNGH Pressable', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
render(
<View>
<Pressable
testID="pressable"
onPress={logEvent('press')}
onPressIn={logEvent('pressIn')}
onPressOut={logEvent('pressOut')}
onLongPress={logEvent('longPress')}
/>
</View>,
);
const pressable = screen.getByTestId('pressable');
await user.press(pressable);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});
@@ -1,5 +1,6 @@
import React from 'react';
import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-native';
import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'react-native';
import type { ReactTestInstance } from 'react-test-renderer';
import { render, screen } from '../../..';
import { createEventLogger, getEventsNames } from '../../../test-utils';
@@ -152,4 +153,35 @@ describe('userEvent.longPress with fake timers', () => {
expect(mockOnLongPress).toHaveBeenCalled();
});
test('longPress accepts custom duration', async () => {
const { events, logEvent } = createEventLogger();
const user = userEvent.setup();
render(
<Pressable
onPress={logEvent('press')}
onPressIn={logEvent('pressIn')}
onPressOut={logEvent('pressOut')}
onLongPress={logEvent('longPress')}
testID="pressable"
/>,
);
await user.longPress(screen.getByTestId('pressable'), { duration: 50 });
expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
});
it('longPress throws on composite components', async () => {
render(<View testID="view" />);
const user = userEvent.setup();
const compositeView = screen.getByTestId('view').parent as ReactTestInstance;
await expect(user.longPress(compositeView)).rejects.toThrowErrorMatchingInlineSnapshot(`
"longPress() works only with host elements. Passed element has type "function Component() {
(0, _classCallCheck2.default)(this, Component);
return _callSuper(this, Component, arguments);
}"."
`);
});
});
@@ -34,6 +34,8 @@ describe('userEvent.press with real timers', () => {
);
await user.press(screen.getByTestId('pressable'));
// Typical event order is pressIn, pressOut, press
// But sometimes due to a race condition, the order is pressIn, press, pressOut.
const eventSequence = getEventsNames(events).join(', ');
expect(
eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut',
@@ -201,11 +203,11 @@ describe('userEvent.press with real timers', () => {
);
await user.press(screen.getByTestId('pressable'));
const eventsNames = getEventsNames(events).join(', ');
const eventSequence = getEventsNames(events).join(', ');
// Typical event order is pressIn, pressOut, press
// But sometimes due to a race condition, the order is pressIn, press, pressOut.
expect(
eventsNames === 'pressIn, pressOut, press' || eventsNames === 'pressIn, press, pressOut',
eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut',
).toBe(true);
});
@@ -8,6 +8,7 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import type { ReactTestInstance } from 'react-test-renderer';
import { render, screen } from '../../..';
import { createEventLogger, getEventsNames } from '../../../test-utils';
@@ -331,6 +332,19 @@ describe('userEvent.press with fake timers', () => {
expect(mockOnPress).toHaveBeenCalled();
});
it('press throws on composite components', async () => {
render(<View testID="view" />);
const user = userEvent.setup();
const compositeView = screen.getByTestId('view').parent as ReactTestInstance;
await expect(user.press(compositeView)).rejects.toThrowErrorMatchingInlineSnapshot(`
"press() works only with host elements. Passed element has type "function Component() {
(0, _classCallCheck2.default)(this, Component);
return _callSuper(this, Component, arguments);
}"."
`);
});
test('disables act environmennt', async () => {
// In this test there is state update during await when typing
// Since wait is not wrapped by act there would be a warning
+63 -61
View File
@@ -1,10 +1,12 @@
import type { ReactTestInstance } from 'react-test-renderer';
import act from '../../act';
import { getHostParent } from '../../helpers/component-tree';
import { isHostText } from '../../helpers/host-component-names';
import { getEventHandler } from '../../event-handler';
import type { HostTestInstance } from '../../helpers/component-tree';
import { getHostParent, isHostElement } from '../../helpers/component-tree';
import { ErrorWithStack } from '../../helpers/errors';
import { isHostText, isHostTextInput } from '../../helpers/host-component-names';
import { isPointerEventEnabled } from '../../helpers/pointer-events';
import { isEditableTextInput } from '../../helpers/text-input';
import { EventBuilder } from '../event-builder';
import type { UserEventConfig, UserEventInstance } from '../setup';
import { dispatchEvent, wait } from '../utils';
@@ -19,6 +21,13 @@ export interface PressOptions {
}
export async function press(this: UserEventInstance, element: ReactTestInstance): Promise<void> {
if (!isHostElement(element)) {
throw new ErrorWithStack(
`press() works only with host elements. Passed element has type "${element.type}".`,
press,
);
}
await basePress(this.config, element, {
type: 'press',
});
@@ -29,6 +38,13 @@ export async function longPress(
element: ReactTestInstance,
options?: PressOptions,
): Promise<void> {
if (!isHostElement(element)) {
throw new ErrorWithStack(
`longPress() works only with host elements. Passed element has type "${element.type}".`,
longPress,
);
}
await basePress(this.config, element, {
type: 'longPress',
duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS,
@@ -42,21 +58,16 @@ interface BasePressOptions {
const basePress = async (
config: UserEventConfig,
element: ReactTestInstance,
element: HostTestInstance,
options: BasePressOptions,
): Promise<void> => {
if (isPressableText(element)) {
await emitTextPressEvents(config, element, options);
return;
}
if (isEditableTextInput(element) && isPointerEventEnabled(element)) {
await emitTextInputPressEvents(config, element, options);
if (isEnabledHostElement(element) && hasPressEventHandler(element)) {
await emitDirectPressEvents(config, element, options);
return;
}
if (isEnabledTouchResponder(element)) {
await emitPressablePressEvents(config, element, options);
await emitPressabilityPressEvents(config, element, options);
return;
}
@@ -68,56 +79,42 @@ const basePress = async (
await basePress(config, hostParentElement, options);
};
const emitPressablePressEvents = async (
config: UserEventConfig,
element: ReactTestInstance,
options: BasePressOptions,
) => {
await wait(config);
dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant());
const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION;
await wait(config, duration);
dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease());
// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
// before emitting the `pressOut` event. We need to wait here, so that
// `press()` function does not return before that.
if (DEFAULT_MIN_PRESS_DURATION - duration > 0) {
await act(async () => {
await wait(config, DEFAULT_MIN_PRESS_DURATION - duration);
});
function isEnabledHostElement(element: HostTestInstance) {
if (!isPointerEventEnabled(element)) {
return false;
}
};
const isEnabledTouchResponder = (element: ReactTestInstance) => {
if (isHostText(element)) {
return element.props.disabled !== true;
}
if (isHostTextInput(element)) {
// @ts-expect-error - workaround incorrect ReactTestInstance type
return element.props.editable !== false;
}
return true;
}
function isEnabledTouchResponder(element: HostTestInstance) {
return isPointerEventEnabled(element) && element.props.onStartShouldSetResponder?.();
};
const isPressableText = (element: ReactTestInstance) => {
const hasPressEventHandler = Boolean(
element.props.onPress ||
element.props.onLongPress ||
element.props.onPressIn ||
element.props.onPressOut,
);
}
function hasPressEventHandler(element: HostTestInstance) {
return (
isHostText(element) &&
isPointerEventEnabled(element) &&
!element.props.disabled &&
hasPressEventHandler
getEventHandler(element, 'press') ||
getEventHandler(element, 'longPress') ||
getEventHandler(element, 'pressIn') ||
getEventHandler(element, 'pressOut')
);
};
}
/**
* Dispatches a press event sequence for Text.
* Dispatches a press event sequence for host elements that have `onPress*` event handlers.
*/
async function emitTextPressEvents(
async function emitDirectPressEvents(
config: UserEventConfig,
element: ReactTestInstance,
element: HostTestInstance,
options: BasePressOptions,
) {
await wait(config);
@@ -141,19 +138,24 @@ async function emitTextPressEvents(
}
}
/**
* Dispatches a press event sequence for TextInput.
*/
async function emitTextInputPressEvents(
async function emitPressabilityPressEvents(
config: UserEventConfig,
element: ReactTestInstance,
element: HostTestInstance,
options: BasePressOptions,
) {
await wait(config);
dispatchEvent(element, 'pressIn', EventBuilder.Common.touch());
// Note: TextInput does not have `onPress`/`onLongPress` props.
dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant());
await wait(config, options.duration);
dispatchEvent(element, 'pressOut', EventBuilder.Common.touch());
const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION;
await wait(config, duration);
dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease());
// React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION
// before emitting the `pressOut` event. We need to wait here, so that
// `press()` function does not return before that.
if (DEFAULT_MIN_PRESS_DURATION - duration > 0) {
await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration));
}
}
@@ -169,9 +169,7 @@ If we wanted to stick with real timers then things get a bit more complex. Let
```jsx
test('render with real timers - sleep', async () => {
render(<TestAsyncComponent />);
await act(async () => {
await sleep(100); // Wait a bit longer than setTimeout in `TestAsyncComponent`
});
await act(() => sleep(100)); // Wait a bit longer than setTimeout in `TestAsyncComponent`
expect(screen.getByText('Count 1')).toBeOnTheScreen();
});
+41 -1
View File
@@ -1601,6 +1601,15 @@ __metadata:
languageName: node
linkType: hard
"@egjs/hammerjs@npm:^2.0.17":
version: 2.0.17
resolution: "@egjs/hammerjs@npm:2.0.17"
dependencies:
"@types/hammerjs": "npm:^2.0.36"
checksum: 10c0/dbedc15a0e633f887c08394bd636faf6a3abd05726dc0909a0e01209d5860a752d9eca5e512da623aecfabe665f49f1d035de3103eb2f9022c5cea692f9cc9be
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0":
version: 4.4.1
resolution: "@eslint-community/eslint-utils@npm:4.4.1"
@@ -2844,6 +2853,7 @@ __metadata:
pretty-format: "npm:^29.7.0"
react: "npm:^19.0.0"
react-native: "npm:0.78.0"
react-native-gesture-handler: "npm:^2.23.1"
react-test-renderer: "npm:^19.0.0"
redent: "npm:^3.0.0"
release-it: "npm:^18.0.0"
@@ -2924,6 +2934,13 @@ __metadata:
languageName: node
linkType: hard
"@types/hammerjs@npm:^2.0.36":
version: 2.0.46
resolution: "@types/hammerjs@npm:2.0.46"
checksum: 10c0/f3c1cb20dc2f0523f7b8c76065078544d50d8ae9b0edc1f62fed657210ed814266ff2dfa835d2c157a075991001eec3b64c88bf92e3e6e895c0db78d05711d06
languageName: node
linkType: hard
"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1":
version: 2.0.6
resolution: "@types/istanbul-lib-coverage@npm:2.0.6"
@@ -6103,6 +6120,15 @@ __metadata:
languageName: node
linkType: hard
"hoist-non-react-statics@npm:^3.3.0":
version: 3.3.2
resolution: "hoist-non-react-statics@npm:3.3.2"
dependencies:
react-is: "npm:^16.7.0"
checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74
languageName: node
linkType: hard
"hosted-git-info@npm:^7.0.0":
version: 7.0.2
resolution: "hosted-git-info@npm:7.0.2"
@@ -9141,7 +9167,7 @@ __metadata:
languageName: node
linkType: hard
"react-is@npm:^16.13.1":
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
@@ -9162,6 +9188,20 @@ __metadata:
languageName: node
linkType: hard
"react-native-gesture-handler@npm:^2.23.1":
version: 2.23.1
resolution: "react-native-gesture-handler@npm:2.23.1"
dependencies:
"@egjs/hammerjs": "npm:^2.0.17"
hoist-non-react-statics: "npm:^3.3.0"
invariant: "npm:^2.2.4"
peerDependencies:
react: "*"
react-native: "*"
checksum: 10c0/02ad664662a529836aaae2f6bce5943ccd3193d33641e6328d1306e6cb82eb5d859c5f9975c557f6e6daf1ffb7e7f6cd8432dac70a727110bff887e554673c7c
languageName: node
linkType: hard
"react-native@npm:0.78.0":
version: 0.78.0
resolution: "react-native@npm:0.78.0"