Skip to content

fix: press event order #1696

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions experiments-app/src/experiments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AccessibilityScreen } from './screens/Accessibility';
import { PressEvents } from './screens/PressEvents';
import { TextInputEventPropagation } from './screens/TextInputEventPropagation';
import { TextInputEvents } from './screens/TextInputEvents';
import { ScrollViewEvents } from './screens/ScrollViewEvents';
Expand All @@ -13,6 +14,11 @@ export const experiments = [
title: 'Accessibility',
component: AccessibilityScreen,
},
{
key: 'PressEvents',
title: 'Press Events',
component: PressEvents,
},
{
key: 'TextInputEvents',
title: 'TextInput Events',
Expand Down
82 changes: 82 additions & 0 deletions experiments-app/src/screens/PressEvents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as React from 'react';
import {
StyleSheet,
SafeAreaView,
Text,
TextInput,
View,
Pressable,
TouchableOpacity,
} from 'react-native';
import { nativeEventLogger, logEvent } from '../utils/helpers';

export function PressEvents() {
const [value, setValue] = React.useState('');

const handleChangeText = (value: string) => {
setValue(value);
logEvent('changeText', value);
};

return (
<SafeAreaView style={styles.container}>
<View style={styles.wrapper}>
<TextInput
style={styles.textInput}
value={value}
onPress={nativeEventLogger('press')}
onPressIn={nativeEventLogger('pressIn')}
onPressOut={nativeEventLogger('pressOut')}
/>
</View>
<View style={styles.wrapper}>
<Text
onPress={nativeEventLogger('press')}
onLongPress={nativeEventLogger('longPress')}
onPressIn={nativeEventLogger('pressIn')}
onPressOut={nativeEventLogger('pressOut')}
>
Text
</Text>
</View>
<View style={styles.wrapper}>
<Pressable
onPress={nativeEventLogger('press')}
onLongPress={nativeEventLogger('longPress')}
onPressIn={nativeEventLogger('pressIn')}
onPressOut={nativeEventLogger('pressOut')}
>
<Text>Pressable</Text>
</Pressable>
</View>
<View style={styles.wrapper}>
<TouchableOpacity
onPress={nativeEventLogger('press')}
onLongPress={nativeEventLogger('longPress')}
onPressIn={nativeEventLogger('pressIn')}
onPressOut={nativeEventLogger('pressOut')}
>
<Text>Pressable</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
},
wrapper: {
padding: 20,
backgroundColor: 'yellow',
},
textInput: {
backgroundColor: 'white',
margin: 20,
padding: 8,
fontSize: 18,
borderWidth: 1,
borderColor: 'grey',
},
});
5 changes: 4 additions & 1 deletion experiments-app/src/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NativeSyntheticEvent } from 'react-native/types';

let lastEventTimeStamp: number | null = null;

export function nativeEventLogger(name: string) {
return (event: NativeSyntheticEvent<unknown>) => {
logEvent(name, event?.nativeEvent);
Expand All @@ -14,5 +16,6 @@ export function customEventLogger(name: string) {

export function logEvent(name: string, ...args: unknown[]) {
// eslint-disable-next-line no-console
console.log(`Event: ${name}`, ...args);
console.log(`[${Date.now() - (lastEventTimeStamp ?? Date.now())}ms] Event: ${name}`, ...args);
lastEventTimeStamp = Date.now();
}
15 changes: 12 additions & 3 deletions src/__tests__/render.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import * as React from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { getConfig, resetToDefaults } from '../config';
import { configure, getConfig, resetToDefaults } from '../config';
import { fireEvent, render, RenderAPI, screen } from '..';

const PLACEHOLDER_FRESHNESS = 'Add custom freshness';
Expand Down Expand Up @@ -247,7 +247,16 @@ test('supports legacy rendering', () => {
expect(screen.root).toBeDefined();
});

test('supports concurrent rendering', () => {
// Enable concurrent rendering globally
configure({ concurrentRoot: true });

test('globally enable concurrent rendering', () => {
render(<View testID="test" />);
expect(screen.root).toBeOnTheScreen();
});

// Enable concurrent rendering locally
test('locally enable concurrent rendering', () => {
render(<View testID="test" />, { concurrentRoot: true });
expect(screen.root).toBeDefined();
expect(screen.root).toBeOnTheScreen();
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
},
},
{
"name": "press",
"name": "pressOut",
"payload": {
"currentTarget": {
"measure": [Function],
Expand All @@ -52,7 +52,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
"pageX": 0,
"pageY": 0,
"target": 0,
"timestamp": 0,
"timestamp": 130,
"touches": [],
},
"persist": [Function],
Expand All @@ -63,7 +63,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
},
},
{
"name": "pressOut",
"name": "press",
"payload": {
"currentTarget": {
"measure": [Function],
Expand All @@ -82,7 +82,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
"pageX": 0,
"pageY": 0,
"target": 0,
"timestamp": 0,
"timestamp": 130,
"touches": [],
},
"persist": [Function],
Expand Down
12 changes: 6 additions & 6 deletions src/user-event/press/__tests__/press.real-timers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('userEvent.press with real timers', () => {
);
await user.press(screen.getByTestId('pressable'));

expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});

test('does not trigger event when pressable is disabled', async () => {
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('userEvent.press with real timers', () => {
);
await user.press(screen.getByTestId('pressable'));

expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});

test('crawls up in the tree to find an element that responds to touch events', async () => {
Expand Down Expand Up @@ -198,7 +198,7 @@ describe('userEvent.press with real timers', () => {
);
await userEvent.press(screen.getByText('press me'));

expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});

test('does not trigger on disabled Text', async () => {
Expand Down Expand Up @@ -240,7 +240,7 @@ describe('userEvent.press with real timers', () => {
expect(events).toEqual([]);
});

test('works on TetInput', async () => {
test('works on TextInput', async () => {
const { events, logEvent } = createEventLogger();

render(
Expand All @@ -255,7 +255,7 @@ describe('userEvent.press with real timers', () => {
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut']);
});

test('does not call onPressIn and onPressOut on non editable TetInput', async () => {
test('does not call onPressIn and onPressOut on non editable TextInput', async () => {
const { events, logEvent } = createEventLogger();

render(
Expand All @@ -270,7 +270,7 @@ describe('userEvent.press with real timers', () => {
expect(events).toEqual([]);
});

test('does not call onPressIn and onPressOut on TetInput with pointer events disabled', async () => {
test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => {
const { events, logEvent } = createEventLogger();

render(
Expand Down
4 changes: 2 additions & 2 deletions src/user-event/press/__tests__/press.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('userEvent.press with fake timers', () => {
);
await user.press(screen.getByTestId('pressable'));

expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});

test('crawls up in the tree to find an element that responds to touch events', async () => {
Expand Down Expand Up @@ -199,7 +199,7 @@ describe('userEvent.press with fake timers', () => {
);

await userEvent.press(screen.getByText('press me'));
expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']);
expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']);
});

test('press works on Button', async () => {
Expand Down
7 changes: 0 additions & 7 deletions src/user-event/press/constants.ts

This file was deleted.

41 changes: 25 additions & 16 deletions src/user-event/press/press.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { ReactTestInstance } from 'react-test-renderer';
import act from '../../act';
import { getHostParent } from '../../helpers/component-tree';
import { isTextInputEditable } from '../../helpers/text-input';
import { isPointerEventEnabled } from '../../helpers/pointer-events';
import { isHostText, isHostTextInput } from '../../helpers/host-component-names';
import { EventBuilder } from '../event-builder';
import { UserEventConfig, UserEventInstance } from '../setup';
import { dispatchEvent, wait } from '../utils';
import { DEFAULT_MIN_PRESS_DURATION } from './constants';

// These are constants defined in the React Native repo
export const DEFAULT_MIN_PRESS_DURATION = 130;
export const DEFAULT_LONG_PRESS_DELAY_MS = 500;

export interface PressOptions {
duration?: number;
Expand All @@ -27,7 +29,7 @@ export async function longPress(
): Promise<void> {
await basePress(this.config, element, {
type: 'longPress',
duration: options?.duration ?? 500,
duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS,
});
}

Expand Down Expand Up @@ -73,18 +75,14 @@ const emitPressablePressEvents = async (

dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant());

await wait(config, options.duration);
// We apply minimum press duration here to ensure that `press` events are emitted after `pressOut`.
// Otherwise, pressables would emit them in the reverse order, which in reality happens only for
// very short presses (< 130ms) and contradicts the React Native docs.
// See: https://reactnative.dev/docs/pressable#onpress
let duration = Math.max(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 - options.duration > 0) {
await act(async () => {
await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration);
});
}
};

const isEnabledTouchResponder = (element: ReactTestInstance) => {
Expand Down Expand Up @@ -118,11 +116,22 @@ async function emitTextPressEvents(
await wait(config);
dispatchEvent(element, 'pressIn', EventBuilder.Common.touch());

// Emit either `press` or `longPress`.
dispatchEvent(element, options.type, EventBuilder.Common.touch());

await wait(config, options.duration);

// Long press events are emitted before `pressOut`.
if (options.type === 'longPress') {
dispatchEvent(element, 'longPress', EventBuilder.Common.touch());
}

dispatchEvent(element, 'pressOut', EventBuilder.Common.touch());

// Regular press events are emitted after `pressOut` according to the React Native docs.
// See: https://reactnative.dev/docs/pressable#onpress
// Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but
// we will ignore that as in reality most pressed would be above the 130ms threshold.
if (options.type === 'press') {
dispatchEvent(element, 'press', EventBuilder.Common.touch());
}
}

/**
Expand Down
Loading