Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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();
}
2 changes: 1 addition & 1 deletion src/__tests__/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,5 @@ test('supports legacy rendering', () => {

test('supports concurrent rendering', () => {
render(<View testID="test" />, { concurrentRoot: true });
expect(screen.root).toBeDefined();
expect(screen.root).toBeOnTheScreen();
});
6 changes: 5 additions & 1 deletion src/fire-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ScrollViewProps,
} from 'react-native';
import act from './act';
import { isHostElement } from './helpers/component-tree';
import { isElementMounted, isHostElement } from './helpers/component-tree';
import { isHostScrollView, isHostTextInput } from './helpers/host-component-names';
import { isPointerEventEnabled } from './helpers/pointer-events';
import { isTextInputEditable } from './helpers/text-input';
Expand Down Expand Up @@ -121,6 +121,10 @@ type EventName = StringWithAutocomplete<
>;

function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) {
if (!isElementMounted(element)) {
return;
}

setNativeStateIfNeeded(element, eventName, data[0]);

const handler = findEventHandler(element, eventName);
Expand Down
6 changes: 5 additions & 1 deletion src/helpers/component-tree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ReactTestInstance } from 'react-test-renderer';

import { screen } from '../screen';
/**
* ReactTestInstance referring to host element.
*/
Expand All @@ -13,6 +13,10 @@ export function isHostElement(element?: ReactTestInstance | null): element is Ho
return typeof element?.type === 'string';
}

export function isElementMounted(element: ReactTestInstance | null) {
return getUnsafeRootElement(element) === screen.UNSAFE_root;
}

/**
* Returns first host ancestor for given element.
* @param element The element start traversing from.
Expand Down
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
28 changes: 26 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 Expand Up @@ -372,3 +372,27 @@ describe('userEvent.press with fake timers', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});

function Component() {
const [mounted, setMounted] = React.useState(true);

const onPressIn = () => {
setMounted(false);
};

return (
<View>
{mounted && (
<Pressable onPressIn={onPressIn}>
<Text>Unmount</Text>
</Pressable>
)}
</View>
);
}

test('unmounts component', async () => {
render(<Component />);
await userEvent.press(screen.getByText('Unmount'));
expect(screen.queryByText('Unmount')).not.toBeOnTheScreen();
});
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
Loading