diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9bc937e29..3ec3a6626 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,11 +56,26 @@ jobs:
uses: ./.github/actions/setup-deps
- name: Test
- run: yarn test:ci
+ run: yarn test:ci:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
+
+ test-concurrent:
+ needs: [install-cache-deps]
+ runs-on: ubuntu-latest
+ name: Test (concurrent mode)
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js and deps
+ uses: ./.github/actions/setup-deps
+
+ - name: Test in concurrent mode
+ run: CONCURRENT_MODE=1 yarn test:ci
+
test-website:
runs-on: ubuntu-latest
name: Test Website
diff --git a/experiments-app/src/experiments.ts b/experiments-app/src/experiments.ts
index fdd485148..af889cfb1 100644
--- a/experiments-app/src/experiments.ts
+++ b/experiments-app/src/experiments.ts
@@ -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';
@@ -13,6 +14,11 @@ export const experiments = [
title: 'Accessibility',
component: AccessibilityScreen,
},
+ {
+ key: 'PressEvents',
+ title: 'Press Events',
+ component: PressEvents,
+ },
{
key: 'TextInputEvents',
title: 'TextInput Events',
diff --git a/experiments-app/src/screens/PressEvents.tsx b/experiments-app/src/screens/PressEvents.tsx
new file mode 100644
index 000000000..a8ba3edcc
--- /dev/null
+++ b/experiments-app/src/screens/PressEvents.tsx
@@ -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 (
+
+
+
+
+
+
+ Text
+
+
+
+
+ Pressable
+
+
+
+
+ Pressable
+
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/experiments-app/src/utils/helpers.ts b/experiments-app/src/utils/helpers.ts
index 5993a46c4..1366177b7 100644
--- a/experiments-app/src/utils/helpers.ts
+++ b/experiments-app/src/utils/helpers.ts
@@ -1,5 +1,7 @@
import { NativeSyntheticEvent } from 'react-native/types';
+let lastEventTimeStamp: number | null = null;
+
export function nativeEventLogger(name: string) {
return (event: NativeSyntheticEvent) => {
logEvent(name, event?.nativeEvent);
@@ -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();
}
diff --git a/jest-setup.ts b/jest-setup.ts
index a4d893a18..9ed60181d 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -1,6 +1,9 @@
-import { resetToDefaults } from './src/pure';
+import { resetToDefaults, configure } from './src/pure';
import './src/matchers/extend-expect';
beforeEach(() => {
resetToDefaults();
+ if (process.env.CONCURRENT_MODE === '1') {
+ configure({ concurrentRoot: true });
+ }
});
diff --git a/package.json b/package.json
index f3ab481d1..dfce46bef 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
"scripts": {
"clean": "del build",
"test": "jest",
- "test:ci": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8",
+ "test:ci": "jest --maxWorkers=2",
+ "test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8",
"typecheck": "tsc",
"copy-flowtypes": "cp typings/index.flow.js build",
"lint": "eslint src --cache",
diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
index aca62f304..b3d2a7ed1 100644
--- a/src/__tests__/config.test.ts
+++ b/src/__tests__/config.test.ts
@@ -1,5 +1,9 @@
import { getConfig, configure, resetToDefaults, configureInternal } from '../config';
+beforeEach(() => {
+ resetToDefaults();
+});
+
test('getConfig() returns existing configuration', () => {
expect(getConfig().asyncUtilTimeout).toEqual(1000);
expect(getConfig().defaultIncludeHiddenElements).toEqual(false);
@@ -12,6 +16,7 @@ test('configure() overrides existing config values', () => {
asyncUtilTimeout: 5000,
defaultDebugOptions: { message: 'debug message' },
defaultIncludeHiddenElements: false,
+ concurrentRoot: false,
});
});
diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx
index 0c09b3904..327b7189e 100644
--- a/src/__tests__/render.test.tsx
+++ b/src/__tests__/render.test.tsx
@@ -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';
@@ -241,3 +241,22 @@ test('render calls detects host component names', () => {
render();
expect(getConfig().hostComponentNames).not.toBeUndefined();
});
+
+test('supports legacy rendering', () => {
+ render(, { concurrentRoot: false });
+ expect(screen.root).toBeDefined();
+});
+
+// Enable concurrent rendering globally
+configure({ concurrentRoot: true });
+
+test('globally enable concurrent rendering', () => {
+ render();
+ expect(screen.root).toBeOnTheScreen();
+});
+
+// Enable concurrent rendering locally
+test('locally enable concurrent rendering', () => {
+ render(, { concurrentRoot: true });
+ expect(screen.root).toBeOnTheScreen();
+});
diff --git a/src/config.ts b/src/config.ts
index c343a3e15..388933cdd 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -13,6 +13,12 @@ export type Config = {
/** Default options for `debug` helper. */
defaultDebugOptions?: Partial;
+
+ /**
+ * Set to `true` to enable concurrent rendering.
+ * Otherwise `render` will default to legacy synchronous rendering.
+ */
+ concurrentRoot: boolean;
};
export type ConfigAliasOptions = {
@@ -37,6 +43,7 @@ export type InternalConfig = Config & {
const defaultConfig: InternalConfig = {
asyncUtilTimeout: 1000,
defaultIncludeHiddenElements: false,
+ concurrentRoot: false,
};
let config = { ...defaultConfig };
diff --git a/src/render.tsx b/src/render.tsx
index 5f31dcb2a..e4a6e22e4 100644
--- a/src/render.tsx
+++ b/src/render.tsx
@@ -1,4 +1,8 @@
-import type { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer';
+import type {
+ ReactTestInstance,
+ ReactTestRenderer,
+ TestRendererOptions,
+} from 'react-test-renderer';
import * as React from 'react';
import { Profiler } from 'react';
import act from './act';
@@ -14,7 +18,18 @@ import { setRenderResult } from './screen';
import { getQueriesForElement } from './within';
export interface RenderOptions {
+ /**
+ * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating
+ * reusable custom render functions for common data providers.
+ */
wrapper?: React.ComponentType;
+
+ /**
+ * Set to `true` to enable concurrent rendering.
+ * Otherwise `render` will default to legacy synchronous rendering.
+ */
+ concurrentRoot?: boolean | undefined;
+
createNodeMock?: (element: React.ReactElement) => unknown;
unstable_validateStringsRenderedWithinText?: boolean;
}
@@ -39,11 +54,18 @@ export function renderInternal(
) {
const {
wrapper: Wrapper,
+ concurrentRoot,
detectHostComponentNames = true,
unstable_validateStringsRenderedWithinText,
- ...testRendererOptions
+ ...rest
} = options || {};
+ const testRendererOptions: TestRendererOptions = {
+ ...rest,
+ // @ts-expect-error incomplete typing on RTR package
+ unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot,
+ };
+
if (detectHostComponentNames) {
configureHostComponentNamesIfNeeded();
}
diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap
index ceb2803f3..068aebacd 100644
--- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap
+++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap
@@ -33,7 +33,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
},
},
{
- "name": "press",
+ "name": "pressOut",
"payload": {
"currentTarget": {
"measure": [Function],
@@ -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],
@@ -63,7 +63,7 @@ exports[`userEvent.press with fake timers calls onPressIn, onPress and onPressOu
},
},
{
- "name": "pressOut",
+ "name": "press",
"payload": {
"currentTarget": {
"measure": [Function],
@@ -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],
diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx
index 1ba53c27e..930bdff0a 100644
--- a/src/user-event/press/__tests__/press.real-timers.test.tsx
+++ b/src/user-event/press/__tests__/press.real-timers.test.tsx
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {
@@ -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(
@@ -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(
@@ -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(
diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx
index c4ff8be74..43ce21c67 100644
--- a/src/user-event/press/__tests__/press.test.tsx
+++ b/src/user-event/press/__tests__/press.test.tsx
@@ -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 () => {
@@ -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 () => {
diff --git a/src/user-event/press/constants.ts b/src/user-event/press/constants.ts
deleted file mode 100644
index 8d237a0db..000000000
--- a/src/user-event/press/constants.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// These are constants defined in the React Native repo
-
-// Used to define the delay before calling onPressOut after a press
-export const DEFAULT_MIN_PRESS_DURATION = 130;
-
-// Default minimum press duration to trigger a long press
-export const DEFAULT_LONG_PRESS_DELAY_MS = 500;
diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts
index c49ea7090..d63df7d17 100644
--- a/src/user-event/press/press.ts
+++ b/src/user-event/press/press.ts
@@ -1,5 +1,4 @@
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';
@@ -7,7 +6,10 @@ 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;
@@ -27,7 +29,7 @@ export async function longPress(
): Promise {
await basePress(this.config, element, {
type: 'longPress',
- duration: options?.duration ?? 500,
+ duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS,
});
}
@@ -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) => {
@@ -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());
+ }
}
/**
diff --git a/website/docs/12.x/docs/api/misc/config.mdx b/website/docs/12.x/docs/api/misc/config.mdx
index 049e82760..bdaabd107 100644
--- a/website/docs/12.x/docs/api/misc/config.mdx
+++ b/website/docs/12.x/docs/api/misc/config.mdx
@@ -7,6 +7,7 @@ type Config = {
asyncUtilTimeout: number;
defaultHidden: boolean;
defaultDebugOptions: Partial;
+ concurrentRoot: boolean;
};
function configure(options: Partial) {}
@@ -26,6 +27,10 @@ This option is also available as `defaultHidden` alias for compatibility with [R
Default [debug options](#debug) to be used when calling `debug()`. These default options will be overridden by the ones you specify directly when calling `debug()`.
+### `concurrentRoot` option
+
+Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering.
+
## `resetToDefaults()`
```ts
diff --git a/website/docs/12.x/docs/api/render.mdx b/website/docs/12.x/docs/api/render.mdx
index 0763e1dfd..ee38878ff 100644
--- a/website/docs/12.x/docs/api/render.mdx
+++ b/website/docs/12.x/docs/api/render.mdx
@@ -32,6 +32,10 @@ wrapper?: React.ComponentType,
This option allows you to wrap the tested component, passed as the first option to the `render()` function, in an additional wrapper component. This is useful for creating reusable custom render functions for common React Context providers.
+#### `concurrentRoot` option
+
+Set to `true` to enable concurrent rendering used in the React Native New Architecture. Otherwise `render` will default to legacy synchronous rendering.
+
#### `createNodeMock` option
```ts