diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2be7fa331..7bf049879 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"labelledby",
+ "Pressability",
"Pressable",
"redent",
"RNTL",
diff --git a/jest.config.js b/jest.config.js
index 56f7a1ffc..ae12db2ca 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,11 +1,17 @@
module.exports = {
preset: 'react-native',
setupFilesAfterEnv: ['./jest-setup.ts'],
- testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'timer-utils'],
+ testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/'],
testTimeout: 60000,
transformIgnorePatterns: [
'/node_modules/(?!(@react-native|react-native|react-native-gesture-handler)/).*/',
],
snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'],
clearMocks: true,
+ collectCoverageFrom: [
+ "src/**/*.{js,jsx,ts,tsx}",
+ "!src/**/__tests__/**",
+ "!src/**/*.test.js",
+ "!src/test-utils/**", // Exclude setup files
+ ],
};
diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx
index b398df774..f3b373df4 100644
--- a/src/__tests__/act.test.tsx
+++ b/src/__tests__/act.test.tsx
@@ -23,10 +23,10 @@ test('render should trigger useEffect', () => {
expect(effectCallback).toHaveBeenCalledTimes(1);
});
-test('update should trigger useEffect', () => {
+test('rerender should trigger useEffect', () => {
const effectCallback = jest.fn();
render();
- screen.update();
+ screen.rerender();
expect(effectCallback).toHaveBeenCalledTimes(2);
});
diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx
new file mode 100644
index 000000000..49e96be94
--- /dev/null
+++ b/src/__tests__/fire-event-async.test.tsx
@@ -0,0 +1,659 @@
+import * as React from 'react';
+import {
+ PanResponder,
+ Pressable,
+ ScrollView,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import { fireEventAsync, render, screen, waitFor } from '..';
+
+type OnPressComponentProps = {
+ onPress: () => void;
+ text: string;
+};
+const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => (
+
+
+ {text}
+
+
+);
+
+type CustomEventComponentProps = {
+ onCustomEvent: () => void;
+};
+const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => (
+
+ Custom event component
+
+);
+
+type MyCustomButtonProps = {
+ handlePress: () => void;
+ text: string;
+};
+const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => (
+
+);
+
+type CustomEventComponentWithCustomNameProps = {
+ handlePress: () => void;
+};
+const CustomEventComponentWithCustomName = ({
+ handlePress,
+}: CustomEventComponentWithCustomNameProps) => (
+
+);
+
+describe('fireEventAsync', () => {
+ test('should invoke specified event', async () => {
+ const onPressMock = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByText('Press me'), 'press');
+
+ expect(onPressMock).toHaveBeenCalled();
+ });
+
+ test('should invoke specified event on parent element', async () => {
+ const onPressMock = jest.fn();
+ const text = 'New press text';
+ render();
+
+ await fireEventAsync(screen.getByText(text), 'press');
+ expect(onPressMock).toHaveBeenCalled();
+ });
+
+ test('should invoke event with custom name', async () => {
+ const handlerMock = jest.fn();
+ const EVENT_DATA = 'event data';
+
+ render(
+
+
+ ,
+ );
+
+ await fireEventAsync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA);
+
+ expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA);
+ });
+});
+
+test('fireEventAsync.press', async () => {
+ const onPressMock = jest.fn();
+ const text = 'Fireevent press';
+ const eventData = {
+ nativeEvent: {
+ pageX: 20,
+ pageY: 30,
+ },
+ };
+ render();
+
+ await fireEventAsync.press(screen.getByText(text), eventData);
+
+ expect(onPressMock).toHaveBeenCalledWith(eventData);
+});
+
+test('fireEventAsync.scroll', async () => {
+ const onScrollMock = jest.fn();
+ const eventData = {
+ nativeEvent: {
+ contentOffset: {
+ y: 200,
+ },
+ },
+ };
+
+ render(
+
+ XD
+ ,
+ );
+
+ await fireEventAsync.scroll(screen.getByText('XD'), eventData);
+
+ expect(onScrollMock).toHaveBeenCalledWith(eventData);
+});
+
+test('fireEventAsync.changeText', async () => {
+ const onChangeTextMock = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByPlaceholderText('Customer placeholder');
+ await fireEventAsync.changeText(input, 'content');
+ expect(onChangeTextMock).toHaveBeenCalledWith('content');
+});
+
+it('sets native state value for unmanaged text inputs', async () => {
+ render();
+
+ const input = screen.getByTestId('input');
+ expect(input).toHaveDisplayValue('');
+
+ await fireEventAsync.changeText(input, 'abc');
+ expect(input).toHaveDisplayValue('abc');
+});
+
+test('custom component with custom event name', async () => {
+ const handlePress = jest.fn();
+
+ render();
+
+ await fireEventAsync(screen.getByText('Custom component'), 'handlePress');
+
+ expect(handlePress).toHaveBeenCalled();
+});
+
+test('event with multiple handler parameters', async () => {
+ const handlePress = jest.fn();
+
+ render();
+
+ await fireEventAsync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2');
+
+ expect(handlePress).toHaveBeenCalledWith('param1', 'param2');
+});
+
+test('should not fire on disabled TouchableOpacity', async () => {
+ const handlePress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ expect(handlePress).not.toHaveBeenCalled();
+});
+
+test('should not fire on disabled Pressable', async () => {
+ const handlePress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ expect(handlePress).not.toHaveBeenCalled();
+});
+
+test('should not fire inside View with pointerEvents="none"', async () => {
+ const onPress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ await fireEventAsync(screen.getByText('Trigger'), 'onPress');
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+test('should not fire inside View with pointerEvents="box-only"', async () => {
+ const onPress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ await fireEventAsync(screen.getByText('Trigger'), 'onPress');
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+test('should fire inside View with pointerEvents="box-none"', async () => {
+ const onPress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ await fireEventAsync(screen.getByText('Trigger'), 'onPress');
+ expect(onPress).toHaveBeenCalledTimes(2);
+});
+
+test('should fire inside View with pointerEvents="auto"', async () => {
+ const onPress = jest.fn();
+ render(
+
+
+ Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ await fireEventAsync(screen.getByText('Trigger'), 'onPress');
+ expect(onPress).toHaveBeenCalledTimes(2);
+});
+
+test('should not fire deeply inside View with pointerEvents="box-only"', async () => {
+ const onPress = jest.fn();
+ render(
+
+
+
+ Trigger
+
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ await fireEventAsync(screen.getByText('Trigger'), 'onPress');
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => {
+ const onTouchStart = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('view'), 'touchStart');
+ expect(onTouchStart).toHaveBeenCalled();
+});
+
+test('should fire non-touch events inside View with pointerEvents="box-none"', async () => {
+ const onLayout = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('view'), 'layout');
+ expect(onLayout).toHaveBeenCalled();
+});
+
+// This test if pointerEvents="box-only" on composite `Pressable` is blocking
+// the 'press' event on host View rendered by pressable.
+test('should fire on Pressable with pointerEvents="box-only', async () => {
+ const onPress = jest.fn();
+ render();
+
+ await fireEventAsync.press(screen.getByTestId('pressable'));
+ expect(onPress).toHaveBeenCalled();
+});
+
+test('should pass event up on disabled TouchableOpacity', async () => {
+ const handleInnerPress = jest.fn();
+ const handleOuterPress = jest.fn();
+ render(
+
+
+ Inner Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Inner Trigger'));
+ expect(handleInnerPress).not.toHaveBeenCalled();
+ expect(handleOuterPress).toHaveBeenCalledTimes(1);
+});
+
+test('should pass event up on disabled Pressable', async () => {
+ const handleInnerPress = jest.fn();
+ const handleOuterPress = jest.fn();
+ render(
+
+
+ Inner Trigger
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Inner Trigger'));
+ expect(handleInnerPress).not.toHaveBeenCalled();
+ expect(handleOuterPress).toHaveBeenCalledTimes(1);
+});
+
+type TestComponentProps = {
+ onPress: () => void;
+ disabled?: boolean;
+};
+const TestComponent = ({ onPress }: TestComponentProps) => {
+ return (
+
+ Trigger Test
+
+ );
+};
+
+test('is not fooled by non-native disabled prop', async () => {
+ const handlePress = jest.fn();
+ render();
+
+ await fireEventAsync.press(screen.getByText('Trigger Test'));
+ expect(handlePress).toHaveBeenCalledTimes(1);
+});
+
+type TestChildTouchableComponentProps = {
+ onPress: () => void;
+ someProp: boolean;
+};
+
+function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) {
+ return (
+
+
+ Trigger
+
+
+ );
+}
+
+test('is not fooled by non-responder wrapping host elements', async () => {
+ const handlePress = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ await fireEventAsync.press(screen.getByText('Trigger'));
+ expect(handlePress).not.toHaveBeenCalled();
+});
+
+type TestDraggableComponentProps = { onDrag: () => void };
+
+function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) {
+ const responderHandlers = PanResponder.create({
+ onMoveShouldSetPanResponder: (_evt, _gestureState) => true,
+ onPanResponderMove: onDrag,
+ }).panHandlers;
+
+ return (
+
+ Trigger
+
+ );
+}
+
+test('has only onMove', async () => {
+ const handleDrag = jest.fn();
+
+ render();
+
+ await fireEventAsync(screen.getByText('Trigger'), 'responderMove', {
+ touchHistory: { mostRecentTimeStamp: '2', touchBank: [] },
+ });
+ expect(handleDrag).toHaveBeenCalled();
+});
+
+// Those events ideally should be triggered through `fireEventAsync.scroll`, but they are handled at the
+// native level, so we need to support manually triggering them
+describe('native events', () => {
+ test('triggers onScrollBeginDrag', async () => {
+ const onScrollBeginDragSpy = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('test-id'), 'onScrollBeginDrag');
+ expect(onScrollBeginDragSpy).toHaveBeenCalled();
+ });
+
+ test('triggers onScrollEndDrag', async () => {
+ const onScrollEndDragSpy = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('test-id'), 'onScrollEndDrag');
+ expect(onScrollEndDragSpy).toHaveBeenCalled();
+ });
+
+ test('triggers onMomentumScrollBegin', async () => {
+ const onMomentumScrollBeginSpy = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollBegin');
+ expect(onMomentumScrollBeginSpy).toHaveBeenCalled();
+ });
+
+ test('triggers onMomentumScrollEnd', async () => {
+ const onMomentumScrollEndSpy = jest.fn();
+ render();
+
+ await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollEnd');
+ expect(onMomentumScrollEndSpy).toHaveBeenCalled();
+ });
+});
+
+describe('React.Suspense integration', () => {
+ let mockPromise: Promise;
+ let resolveMockPromise: (value: string) => void;
+
+ beforeEach(() => {
+ mockPromise = new Promise((resolve) => {
+ resolveMockPromise = resolve;
+ });
+ });
+
+ type AsyncComponentProps = {
+ onPress: () => void;
+ shouldSuspend: boolean;
+ };
+
+ function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) {
+ if (shouldSuspend) {
+ throw mockPromise;
+ }
+
+ return (
+
+ Async Component Loaded
+
+ );
+ }
+
+ function SuspenseWrapper({ children }: { children: React.ReactNode }) {
+ return Loading...}>{children};
+ }
+
+ test('should handle events after Suspense resolves', async () => {
+ const onPressMock = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ // Initially shows fallback
+ expect(screen.getByText('Loading...')).toBeTruthy();
+
+ // Resolve the promise
+ resolveMockPromise('loaded');
+ await waitFor(() => {
+ screen.rerender(
+
+
+ ,
+ );
+ });
+
+ // Component should be loaded now
+ await waitFor(() => {
+ expect(screen.getByText('Async Component Loaded')).toBeTruthy();
+ });
+
+ // fireEventAsync should work on the resolved component
+ await fireEventAsync.press(screen.getByText('Async Component Loaded'));
+ expect(onPressMock).toHaveBeenCalled();
+ });
+
+ test('should handle events on Suspense fallback components', async () => {
+ const fallbackPressMock = jest.fn();
+
+ function InteractiveFallback() {
+ return (
+
+ Loading with button...
+
+ );
+ }
+
+ render(
+ }>
+
+ ,
+ );
+
+ // Should be able to interact with fallback
+ expect(screen.getByText('Loading with button...')).toBeTruthy();
+
+ await fireEventAsync.press(screen.getByText('Loading with button...'));
+ expect(fallbackPressMock).toHaveBeenCalled();
+ });
+
+ test('should work with nested Suspense boundaries', async () => {
+ const outerPressMock = jest.fn();
+ const innerPressMock = jest.fn();
+
+ type NestedAsyncProps = {
+ onPress: () => void;
+ shouldSuspend: boolean;
+ level: string;
+ };
+
+ function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) {
+ if (shouldSuspend) {
+ throw mockPromise;
+ }
+
+ return (
+
+ {level} Component Loaded
+
+ );
+ }
+
+ const { rerender } = render(
+ Outer Loading...}>
+
+ Inner Loading...}>
+
+
+ ,
+ );
+
+ // Outer component should be loaded, inner should show fallback
+ expect(screen.getByText('Outer Component Loaded')).toBeTruthy();
+ expect(screen.getByText('Inner Loading...')).toBeTruthy();
+
+ // Should be able to interact with outer component
+ await fireEventAsync.press(screen.getByText('Outer Component Loaded'));
+ expect(outerPressMock).toHaveBeenCalled();
+
+ // Resolve inner component
+ resolveMockPromise('inner-loaded');
+ await waitFor(() => {
+ rerender(
+ Outer Loading...}>
+
+ Inner Loading...}>
+
+
+ ,
+ );
+ });
+
+ // Both components should be loaded now
+ await waitFor(() => {
+ expect(screen.getByText('Inner Component Loaded')).toBeTruthy();
+ });
+
+ // Should be able to interact with inner component
+ await fireEventAsync.press(screen.getByText('Inner Component Loaded'));
+ expect(innerPressMock).toHaveBeenCalled();
+ });
+
+ test('should work when events cause components to suspend', async () => {
+ const onPressMock = jest.fn();
+ let shouldSuspend = false;
+
+ function DataComponent() {
+ if (shouldSuspend) {
+ throw mockPromise; // This will cause suspense
+ }
+ return Data loaded;
+ }
+
+ function ButtonComponent() {
+ return (
+ {
+ onPressMock();
+ shouldSuspend = true; // This will cause DataComponent to suspend on next render
+ }}
+ >
+ Load Data
+
+ );
+ }
+
+ render(
+
+
+ Loading data...}>
+
+
+ ,
+ );
+
+ // Initially data is loaded
+ expect(screen.getByText('Data loaded')).toBeTruthy();
+
+ // Click button - this triggers the state change that will cause suspension
+ await fireEventAsync.press(screen.getByText('Load Data'));
+ expect(onPressMock).toHaveBeenCalled();
+
+ // Rerender - now DataComponent should suspend
+ screen.rerender(
+
+
+ Loading data...}>
+
+
+ ,
+ );
+
+ // Should show loading fallback
+ expect(screen.getByText('Loading data...')).toBeTruthy();
+ });
+});
+
+test('should handle unmounted elements gracefully in async mode', async () => {
+ const onPress = jest.fn();
+ render(
+
+ Test
+ ,
+ );
+
+ const element = screen.getByText('Test');
+ screen.unmount();
+
+ // Firing async event on unmounted element should not crash
+ await fireEventAsync.press(element);
+ expect(onPress).not.toHaveBeenCalled();
+});
diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx
index f5e05486c..7e3474bb0 100644
--- a/src/__tests__/fire-event.test.tsx
+++ b/src/__tests__/fire-event.test.tsx
@@ -553,3 +553,19 @@ describe('native events', () => {
expect(onMomentumScrollEndSpy).toHaveBeenCalled();
});
});
+
+test('should handle unmounted elements gracefully', () => {
+ const onPress = jest.fn();
+ render(
+
+ Test
+ ,
+ );
+
+ const element = screen.getByText('Test');
+ screen.unmount();
+
+ // Firing event on unmounted element should not crash
+ fireEvent.press(element);
+ expect(onPress).not.toHaveBeenCalled();
+});
diff --git a/src/__tests__/react-native-gesture-handler.test.tsx b/src/__tests__/react-native-gesture-handler.test.tsx
index 644090f36..989ad03cf 100644
--- a/src/__tests__/react-native-gesture-handler.test.tsx
+++ b/src/__tests__/react-native-gesture-handler.test.tsx
@@ -4,7 +4,7 @@ import { View } from 'react-native';
import { Pressable } from 'react-native-gesture-handler';
import { fireEvent, render, screen, userEvent } from '..';
-import { createEventLogger, getEventsNames } from '../test-utils';
+import { createEventLogger, getEventsNames } from '../test-utils/events';
test('fireEvent can invoke press events for RNGH Pressable', () => {
const onPress = jest.fn();
diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx
new file mode 100644
index 000000000..8fad2cc31
--- /dev/null
+++ b/src/__tests__/render-async.test.tsx
@@ -0,0 +1,117 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { renderAsync, screen } from '..';
+
+class Banana extends React.Component {
+ state = {
+ fresh: false,
+ };
+
+ componentDidUpdate() {
+ if (this.props.onUpdate) {
+ this.props.onUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.onUnmount) {
+ this.props.onUnmount();
+ }
+ }
+
+ changeFresh = () => {
+ this.setState((state) => ({
+ fresh: !state.fresh,
+ }));
+ };
+
+ render() {
+ return (
+
+ Is the banana fresh?
+ {this.state.fresh ? 'fresh' : 'not fresh'}
+
+ );
+ }
+}
+
+test('renderAsync renders component asynchronously', async () => {
+ await renderAsync();
+ expect(screen.getByTestId('test')).toBeOnTheScreen();
+});
+
+test('renderAsync with wrapper option', async () => {
+ const WrapperComponent = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ await renderAsync(, {
+ wrapper: WrapperComponent,
+ });
+
+ expect(screen.getByTestId('wrapper')).toBeTruthy();
+ expect(screen.getByTestId('inner')).toBeTruthy();
+});
+
+test('renderAsync supports concurrent rendering option', async () => {
+ await renderAsync(, { concurrentRoot: true });
+ expect(screen.root).toBeOnTheScreen();
+});
+
+test('rerender function throws error when used with renderAsync', async () => {
+ await renderAsync();
+
+ expect(() => screen.rerender()).toThrowErrorMatchingInlineSnapshot(
+ `""rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead"`,
+ );
+});
+
+test('rerenderAsync function updates component asynchronously', async () => {
+ const fn = jest.fn();
+ await renderAsync();
+ expect(fn).toHaveBeenCalledTimes(0);
+
+ await screen.rerenderAsync();
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('unmount function throws error when used with renderAsync', async () => {
+ await renderAsync();
+
+ expect(() => screen.unmount()).toThrowErrorMatchingInlineSnapshot(
+ `""unmount()" is not supported when using "renderAsync" use "await unmountAsync()" instead"`,
+ );
+});
+
+test('unmountAsync function unmounts component asynchronously', async () => {
+ const fn = jest.fn();
+ await renderAsync();
+
+ await screen.unmountAsync();
+ expect(fn).toHaveBeenCalled();
+});
+
+test('container property displays deprecation message', async () => {
+ await renderAsync();
+
+ expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(`
+ "'container' property has been renamed to 'UNSAFE_root'.
+
+ Consider using 'root' property which returns root host element."
+ `);
+});
+
+test('debug function handles null JSON', async () => {
+ const result = await renderAsync();
+
+ // Mock toJSON to return null to test the debug edge case
+ const originalToJSON = result.toJSON;
+ (result as any).toJSON = jest.fn().mockReturnValue(null);
+
+ // This should not throw and handle null JSON gracefully
+ expect(() => result.debug()).not.toThrow();
+
+ // Restore original toJSON
+ (result as any).toJSON = originalToJSON;
+});
diff --git a/src/__tests__/render-string-validation.test.tsx b/src/__tests__/render-string-validation.test.tsx
index 0595c098a..9ac25a01f 100644
--- a/src/__tests__/render-string-validation.test.tsx
+++ b/src/__tests__/render-string-validation.test.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Pressable, Text, View } from 'react-native';
import { fireEvent, render, screen } from '..';
+import { excludeConsoleMessage } from '../test-utils/console';
// eslint-disable-next-line no-console
const originalConsoleError = console.error;
@@ -12,13 +13,8 @@ const PROFILER_ERROR = 'The above error occurred in the component';
beforeEach(() => {
// eslint-disable-next-line no-console
- console.error = (errorMessage: string) => {
- if (!errorMessage.includes(PROFILER_ERROR)) {
- originalConsoleError(errorMessage);
- }
- };
+ console.error = excludeConsoleMessage(console.error, PROFILER_ERROR);
});
-
afterEach(() => {
// eslint-disable-next-line no-console
console.error = originalConsoleError;
diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx
index 6aa0769dd..48151662b 100644
--- a/src/__tests__/render.test.tsx
+++ b/src/__tests__/render.test.tsx
@@ -110,16 +110,16 @@ test('UNSAFE_getAllByProp, UNSAFE_queryAllByProps', () => {
expect(screen.UNSAFE_queryAllByProps({ type: 'inexistent' })).toHaveLength(0);
});
-test('update', () => {
+test('rerender', () => {
const fn = jest.fn();
render();
+ expect(fn).toHaveBeenCalledTimes(0);
fireEvent.press(screen.getByText('Change freshness!'));
+ expect(fn).toHaveBeenCalledTimes(1);
- screen.update();
screen.rerender();
-
- expect(fn).toHaveBeenCalledTimes(3);
+ expect(fn).toHaveBeenCalledTimes(2);
});
test('unmount', () => {
@@ -243,3 +243,30 @@ test('supports concurrent rendering', () => {
render(, { concurrentRoot: true });
expect(screen.root).toBeOnTheScreen();
});
+
+test('rerenderAsync updates the component asynchronously', async () => {
+ const fn = jest.fn();
+ const result = render();
+
+ await result.rerenderAsync();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('updateAsync is an alias for rerenderAsync', async () => {
+ const fn = jest.fn();
+ const result = render();
+
+ await result.updateAsync();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('unmountAsync unmounts the component asynchronously', async () => {
+ const fn = jest.fn();
+ const result = render();
+
+ await result.unmountAsync();
+
+ expect(fn).toHaveBeenCalled();
+});
diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx
new file mode 100644
index 000000000..ffa4ef8be
--- /dev/null
+++ b/src/__tests__/suspense-fake-timers.test.tsx
@@ -0,0 +1,192 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { act, renderAsync, screen } from '..';
+import { excludeConsoleMessage } from '../test-utils/console';
+
+jest.useFakeTimers();
+
+const testGateReact19 = React.version.startsWith('19.') ? test : test.skip;
+
+// eslint-disable-next-line no-console
+const originalConsoleError = console.error;
+afterEach(() => {
+ // eslint-disable-next-line no-console
+ console.error = originalConsoleError;
+});
+
+function Suspending({ promise, testID }: { promise: Promise; testID: string }) {
+ React.use(promise);
+ return ;
+}
+
+testGateReact19('resolves manually-controlled promise', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise(null));
+ expect(screen.getByTestId('content')).toBeOnTheScreen();
+ expect(screen.getByTestId('sibling')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+testGateReact19('resolves timer-controlled promise', async () => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => resolve(null), 100);
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen();
+
+ expect(await screen.findByTestId('content')).toBeOnTheScreen();
+ expect(screen.getByTestId('content')).toBeOnTheScreen();
+ expect(screen.getByTestId('sibling')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+class ErrorBoundary extends React.Component<
+ { children: React.ReactNode; fallback: React.ReactNode },
+ { hasError: boolean }
+> {
+ constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ render() {
+ return this.state.hasError ? this.props.fallback : this.props.children;
+ }
+}
+
+testGateReact19('handles promise rejection with error boundary', async () => {
+ const ERROR_MESSAGE = 'Promise Rejected In Test';
+ // eslint-disable-next-line no-console
+ console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);
+
+ let rejectPromise: (error: Error) => void;
+ const promise = new Promise((_resolve, reject) => {
+ rejectPromise = reject;
+ });
+
+ await renderAsync(
+ Error occurred}>
+ Loading...}>
+
+
+ ,
+ );
+
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));
+
+ expect(screen.getByText('Error occurred')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen();
+});
+
+testGateReact19('handles multiple suspending components', async () => {
+ let resolvePromise1: (value: unknown) => void;
+ let resolvePromise2: (value: unknown) => void;
+
+ const promise1 = new Promise((resolve) => {
+ resolvePromise1 = resolve;
+ });
+ const promise2 = new Promise((resolve) => {
+ resolvePromise2 = resolve;
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise1(null));
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise2(null));
+ expect(screen.getByTestId('content-1')).toBeOnTheScreen();
+ expect(screen.getByTestId('content-2')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+testGateReact19('handles multiple suspense boundaries independently', async () => {
+ let resolvePromise1: (value: unknown) => void;
+ let resolvePromise2: (value: unknown) => void;
+
+ const promise1 = new Promise((resolve) => {
+ resolvePromise1 = resolve;
+ });
+ const promise2 = new Promise((resolve) => {
+ resolvePromise2 = resolve;
+ });
+
+ await renderAsync(
+
+ First Loading...}>
+
+
+ Second Loading...}>
+
+
+ ,
+ );
+
+ expect(screen.getByText('First Loading...')).toBeOnTheScreen();
+ expect(screen.getByText('Second Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise1(null));
+ expect(screen.getByTestId('content-1')).toBeOnTheScreen();
+ expect(screen.queryByText('First Loading...')).not.toBeOnTheScreen();
+ expect(screen.getByText('Second Loading...')).toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise2(null));
+ expect(screen.getByTestId('content-2')).toBeOnTheScreen();
+ expect(screen.queryByText('Second Loading...')).not.toBeOnTheScreen();
+});
diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx
new file mode 100644
index 000000000..a22ba1ed3
--- /dev/null
+++ b/src/__tests__/suspense.test.tsx
@@ -0,0 +1,189 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { act, renderAsync, screen } from '..';
+import { excludeConsoleMessage } from '../test-utils/console';
+
+const testGateReact19 = React.version.startsWith('19.') ? test : test.skip;
+
+// eslint-disable-next-line no-console
+const originalConsoleError = console.error;
+afterEach(() => {
+ // eslint-disable-next-line no-console
+ console.error = originalConsoleError;
+});
+
+function Suspending({ promise, testID }: { promise: Promise; testID: string }) {
+ React.use(promise);
+ return ;
+}
+
+testGateReact19('resolves manually-controlled promise', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise(null));
+ expect(screen.getByTestId('content')).toBeOnTheScreen();
+ expect(screen.getByTestId('sibling')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+testGateReact19('resolves timer-controlled promise', async () => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => resolve(null), 100);
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen();
+
+ expect(await screen.findByTestId('content')).toBeOnTheScreen();
+ expect(screen.getByTestId('sibling')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+class ErrorBoundary extends React.Component<
+ { children: React.ReactNode; fallback: React.ReactNode },
+ { hasError: boolean }
+> {
+ constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ render() {
+ return this.state.hasError ? this.props.fallback : this.props.children;
+ }
+}
+
+testGateReact19('handles promise rejection with error boundary', async () => {
+ const ERROR_MESSAGE = 'Promise Rejected In Test';
+ // eslint-disable-next-line no-console
+ console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);
+
+ let rejectPromise: (error: Error) => void;
+ const promise = new Promise((_resolve, reject) => {
+ rejectPromise = reject;
+ });
+
+ await renderAsync(
+ Error occurred}>
+ Loading...}>
+
+
+ ,
+ );
+
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));
+
+ expect(screen.getByText('Error occurred')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen();
+});
+
+testGateReact19('handles multiple suspending components', async () => {
+ let resolvePromise1: (value: unknown) => void;
+ let resolvePromise2: (value: unknown) => void;
+
+ const promise1 = new Promise((resolve) => {
+ resolvePromise1 = resolve;
+ });
+ const promise2 = new Promise((resolve) => {
+ resolvePromise2 = resolve;
+ });
+
+ await renderAsync(
+
+ Loading...}>
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise1(null));
+ expect(screen.getByText('Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise2(null));
+ expect(screen.getByTestId('content-1')).toBeOnTheScreen();
+ expect(screen.getByTestId('content-2')).toBeOnTheScreen();
+ expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
+});
+
+testGateReact19('handles multiple suspense boundaries independently', async () => {
+ let resolvePromise1: (value: unknown) => void;
+ let resolvePromise2: (value: unknown) => void;
+
+ const promise1 = new Promise((resolve) => {
+ resolvePromise1 = resolve;
+ });
+ const promise2 = new Promise((resolve) => {
+ resolvePromise2 = resolve;
+ });
+
+ await renderAsync(
+
+ First Loading...}>
+
+
+ Second Loading...}>
+
+
+ ,
+ );
+
+ expect(screen.getByText('First Loading...')).toBeOnTheScreen();
+ expect(screen.getByText('Second Loading...')).toBeOnTheScreen();
+ expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise1(null));
+ expect(screen.getByTestId('content-1')).toBeOnTheScreen();
+ expect(screen.queryByText('First Loading...')).not.toBeOnTheScreen();
+ expect(screen.getByText('Second Loading...')).toBeOnTheScreen();
+
+ // eslint-disable-next-line require-await
+ await act(async () => resolvePromise2(null));
+ expect(screen.getByTestId('content-2')).toBeOnTheScreen();
+ expect(screen.queryByText('Second Loading...')).not.toBeOnTheScreen();
+});
diff --git a/src/__tests__/timer-utils.ts b/src/__tests__/timer-utils.ts
deleted file mode 100644
index abe13edea..000000000
--- a/src/__tests__/timer-utils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { setTimeout } from '../helpers/timers';
-
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-export { sleep };
diff --git a/src/fire-event.ts b/src/fire-event.ts
index 9ec20f5ca..981e6e649 100644
--- a/src/fire-event.ts
+++ b/src/fire-event.ts
@@ -135,6 +135,41 @@ fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) =>
fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) =>
fireEvent(element, 'scroll', ...data);
+async function fireEventAsync(
+ element: ReactTestInstance,
+ eventName: EventName,
+ ...data: unknown[]
+) {
+ if (!isElementMounted(element)) {
+ return;
+ }
+
+ setNativeStateIfNeeded(element, eventName, data[0]);
+
+ const handler = findEventHandler(element, eventName);
+ if (!handler) {
+ return;
+ }
+
+ let returnValue;
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ returnValue = handler(...data);
+ });
+
+ return returnValue;
+}
+
+fireEventAsync.press = async (element: ReactTestInstance, ...data: unknown[]) =>
+ await fireEventAsync(element, 'press', ...data);
+
+fireEventAsync.changeText = async (element: ReactTestInstance, ...data: unknown[]) =>
+ await fireEventAsync(element, 'changeText', ...data);
+
+fireEventAsync.scroll = async (element: ReactTestInstance, ...data: unknown[]) =>
+ await fireEventAsync(element, 'scroll', ...data);
+
+export { fireEventAsync };
export default fireEvent;
const scrollEventNames = new Set([
diff --git a/src/pure.ts b/src/pure.ts
index f4aa4f7a0..62be84c21 100644
--- a/src/pure.ts
+++ b/src/pure.ts
@@ -1,7 +1,8 @@
export { default as act } from './act';
export { default as cleanup } from './cleanup';
-export { default as fireEvent } from './fire-event';
+export { default as fireEvent, fireEventAsync } from './fire-event';
export { default as render } from './render';
+export { default as renderAsync } from './render-async';
export { default as waitFor } from './wait-for';
export { default as waitForElementToBeRemoved } from './wait-for-element-to-be-removed';
export { within, getQueriesForElement } from './within';
@@ -19,6 +20,7 @@ export type {
RenderResult as RenderAPI,
DebugFunction,
} from './render';
+export type { RenderAsyncOptions, RenderAsyncResult } from './render-async';
export type { RenderHookOptions, RenderHookResult } from './render-hook';
export type { Config } from './config';
export type { UserEventConfig } from './user-event';
diff --git a/src/render-act.ts b/src/render-act.ts
index 3bba04ea1..a463ad331 100644
--- a/src/render-act.ts
+++ b/src/render-act.ts
@@ -18,3 +18,19 @@ export function renderWithAct(
// @ts-expect-error: `act` is synchronous, so `renderer` is already initialized here
return renderer;
}
+
+export async function renderWithAsyncAct(
+ component: React.ReactElement,
+ options?: Partial,
+): Promise {
+ let renderer: ReactTestRenderer;
+
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ // @ts-expect-error `TestRenderer.create` is not typed correctly
+ renderer = TestRenderer.create(component, options);
+ });
+
+ // @ts-expect-error: `renderer` is already initialized here
+ return renderer;
+}
diff --git a/src/render-async.tsx b/src/render-async.tsx
new file mode 100644
index 000000000..59f916bfd
--- /dev/null
+++ b/src/render-async.tsx
@@ -0,0 +1,139 @@
+import * as React from 'react';
+import type {
+ ReactTestInstance,
+ ReactTestRenderer,
+ TestRendererOptions,
+} from 'react-test-renderer';
+
+import act from './act';
+import { addToCleanupQueue } from './cleanup';
+import { getConfig } from './config';
+import { getHostSelves } from './helpers/component-tree';
+import type { DebugOptions } from './helpers/debug';
+import { debug } from './helpers/debug';
+import { ErrorWithStack } from './helpers/errors';
+import { renderWithAsyncAct } from './render-act';
+import { setRenderResult } from './screen';
+import { getQueriesForElement } from './within';
+
+export interface RenderAsyncOptions {
+ /**
+ * 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.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ wrapper?: React.ComponentType;
+
+ /**
+ * Set to `false` to disable concurrent rendering.
+ * Otherwise `render` will default to concurrent rendering.
+ */
+ // TODO: should we assume concurrentRoot is true for react suspense?
+ concurrentRoot?: boolean;
+
+ createNodeMock?: (element: React.ReactElement) => unknown;
+}
+
+export type RenderAsyncResult = ReturnType;
+
+/**
+ * Renders test component deeply using React Test Renderer and exposes helpers
+ * to assert on the output.
+ */
+export default async function renderAsync(
+ component: React.ReactElement,
+ options: RenderAsyncOptions = {},
+) {
+ const { wrapper: Wrapper, concurrentRoot, ...rest } = options || {};
+
+ const testRendererOptions: TestRendererOptions = {
+ ...rest,
+ // @ts-expect-error incomplete typing on RTR package
+ unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot,
+ };
+
+ const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element);
+ const renderer = await renderWithAsyncAct(wrap(component), testRendererOptions);
+ return buildRenderResult(renderer, wrap);
+}
+
+function buildRenderResult(
+ renderer: ReactTestRenderer,
+ wrap: (element: React.ReactElement) => React.JSX.Element,
+) {
+ const instance = renderer.root;
+
+ const rerender = (_component: React.ReactElement) => {
+ throw new ErrorWithStack(
+ '"rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead',
+ rerender,
+ );
+ };
+ const rerenderAsync = async (component: React.ReactElement) => {
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ renderer.update(wrap(component));
+ });
+ };
+
+ const unmount = () => {
+ throw new ErrorWithStack(
+ '"unmount()" is not supported when using "renderAsync" use "await unmountAsync()" instead',
+ unmount,
+ );
+ };
+ const unmountAsync = async () => {
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ renderer.unmount();
+ });
+ };
+
+ addToCleanupQueue(unmountAsync);
+
+ const result = {
+ ...getQueriesForElement(instance),
+ rerender,
+ rerenderAsync,
+ update: rerender, // alias for `rerender`
+ updateAsync: rerenderAsync, // alias for `rerenderAsync`
+ unmount,
+ unmountAsync,
+ toJSON: renderer.toJSON,
+ debug: makeDebug(renderer),
+ get root(): ReactTestInstance {
+ return getHostSelves(instance)[0];
+ },
+ UNSAFE_root: instance,
+ };
+
+ // Add as non-enumerable property, so that it's safe to enumerate
+ // `render` result, e.g. using destructuring rest syntax.
+ Object.defineProperty(result, 'container', {
+ enumerable: false,
+ get() {
+ throw new Error(
+ "'container' property has been renamed to 'UNSAFE_root'.\n\n" +
+ "Consider using 'root' property which returns root host element.",
+ );
+ },
+ });
+
+ setRenderResult(result);
+
+ return result;
+}
+
+export type DebugFunction = (options?: DebugOptions) => void;
+
+function makeDebug(renderer: ReactTestRenderer): DebugFunction {
+ function debugImpl(options?: DebugOptions) {
+ const { defaultDebugOptions } = getConfig();
+ const debugOptions = { ...defaultDebugOptions, ...options };
+ const json = renderer.toJSON();
+ if (json) {
+ return debug(json, debugOptions);
+ }
+ }
+ return debugImpl;
+}
diff --git a/src/render.tsx b/src/render.tsx
index 3555d8f4e..d103e13dc 100644
--- a/src/render.tsx
+++ b/src/render.tsx
@@ -98,22 +98,42 @@ function buildRenderResult(
renderer: ReactTestRenderer,
wrap: (element: React.ReactElement) => React.JSX.Element,
) {
- const update = updateWithAct(renderer, wrap);
const instance = renderer.root;
+ const rerender = (component: React.ReactElement) => {
+ void act(() => {
+ renderer.update(wrap(component));
+ });
+ };
+ const rerenderAsync = async (component: React.ReactElement) => {
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ renderer.update(wrap(component));
+ });
+ };
+
const unmount = () => {
void act(() => {
renderer.unmount();
});
};
+ const unmountAsync = async () => {
+ // eslint-disable-next-line require-await
+ await act(async () => {
+ renderer.unmount();
+ });
+ };
addToCleanupQueue(unmount);
const result = {
...getQueriesForElement(instance),
- update,
+ rerender,
+ rerenderAsync,
+ update: rerender, // alias for 'rerender'
+ updateAsync: rerenderAsync, // alias for `rerenderAsync`
unmount,
- rerender: update, // alias for `update`
+ unmountAsync,
toJSON: renderer.toJSON,
debug: makeDebug(renderer),
get root(): ReactTestInstance {
@@ -139,17 +159,6 @@ function buildRenderResult(
return result;
}
-function updateWithAct(
- renderer: ReactTestRenderer,
- wrap: (innerElement: React.ReactElement) => React.ReactElement,
-) {
- return function (component: React.ReactElement) {
- void act(() => {
- renderer.update(wrap(component));
- });
- };
-}
-
export type DebugFunction = (options?: DebugOptions) => void;
function makeDebug(renderer: ReactTestRenderer): DebugFunction {
diff --git a/src/screen.ts b/src/screen.ts
index d5edc0733..382aacf57 100644
--- a/src/screen.ts
+++ b/src/screen.ts
@@ -25,9 +25,12 @@ const defaultScreen: Screen = {
throw new Error(SCREEN_ERROR);
},
debug: notImplementedDebug,
+ rerender: notImplemented,
+ rerenderAsync: notImplemented,
update: notImplemented,
+ updateAsync: notImplemented,
unmount: notImplemented,
- rerender: notImplemented,
+ unmountAsync: notImplemented,
toJSON: notImplemented,
getByLabelText: notImplemented,
getAllByLabelText: notImplemented,
diff --git a/src/test-utils/console.ts b/src/test-utils/console.ts
new file mode 100644
index 000000000..428a48836
--- /dev/null
+++ b/src/test-utils/console.ts
@@ -0,0 +1,12 @@
+import { format } from 'util';
+
+export function excludeConsoleMessage(logFn: (...args: unknown[]) => void, excludeMessage: string) {
+ return (...args: unknown[]) => {
+ const message = format(...args);
+ if (message.includes(excludeMessage)) {
+ return;
+ }
+
+ logFn(...args);
+ };
+}
diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts
deleted file mode 100644
index 7981d6b64..000000000
--- a/src/test-utils/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './events';
diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx
index 85d729e8d..712e73dfe 100644
--- a/src/user-event/__tests__/clear.test.tsx
+++ b/src/user-event/__tests__/clear.test.tsx
@@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native';
import { TextInput, View } from 'react-native';
import { render, screen, userEvent } from '../..';
-import { createEventLogger, getEventsNames } from '../../test-utils';
+import { createEventLogger, getEventsNames } from '../../test-utils/events';
beforeEach(() => {
jest.useRealTimers();
diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx
index 2392ac87b..cf254a1ad 100644
--- a/src/user-event/__tests__/paste.test.tsx
+++ b/src/user-event/__tests__/paste.test.tsx
@@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native';
import { TextInput, View } from 'react-native';
import { render, screen, userEvent } from '../..';
-import { createEventLogger, getEventsNames } from '../../test-utils';
+import { createEventLogger, getEventsNames } from '../../test-utils/events';
beforeEach(() => {
jest.useRealTimers();
diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts
index 20ee66f85..4a0701873 100644
--- a/src/user-event/clear.ts
+++ b/src/user-event/clear.ts
@@ -22,7 +22,7 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance)
}
// 1. Enter element
- dispatchEvent(element, 'focus', EventBuilder.Common.focus());
+ await dispatchEvent(element, 'focus', EventBuilder.Common.focus());
// 2. Select all
const textToClear = getTextInputValue(element);
@@ -30,7 +30,11 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance)
start: 0,
end: textToClear.length,
};
- dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange));
+ await dispatchEvent(
+ element,
+ 'selectionChange',
+ EventBuilder.TextInput.selectionChange(selectionRange),
+ );
// 3. Press backspace with selected text
const emptyText = '';
@@ -42,6 +46,6 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance)
// 4. Exit element
await wait(this.config);
- dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText));
- dispatchEvent(element, 'blur', EventBuilder.Common.blur());
+ await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText));
+ await dispatchEvent(element, 'blur', EventBuilder.Common.blur());
}
diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts
index 9abb3f79b..98191d846 100644
--- a/src/user-event/paste.ts
+++ b/src/user-event/paste.ts
@@ -26,27 +26,35 @@ export async function paste(
}
// 1. Enter element
- dispatchEvent(element, 'focus', EventBuilder.Common.focus());
+ await dispatchEvent(element, 'focus', EventBuilder.Common.focus());
// 2. Select all
const textToClear = getTextInputValue(element);
const rangeToClear = { start: 0, end: textToClear.length };
- dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear));
+ await dispatchEvent(
+ element,
+ 'selectionChange',
+ EventBuilder.TextInput.selectionChange(rangeToClear),
+ );
// 3. Paste the text
nativeState.valueForElement.set(element, text);
- dispatchEvent(element, 'change', EventBuilder.TextInput.change(text));
- dispatchEvent(element, 'changeText', text);
+ await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text));
+ await dispatchEvent(element, 'changeText', text);
const rangeAfter = { start: text.length, end: text.length };
- dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeAfter));
+ await dispatchEvent(
+ element,
+ 'selectionChange',
+ EventBuilder.TextInput.selectionChange(rangeAfter),
+ );
// According to the docs only multiline TextInput emits contentSizeChange event
// @see: https://reactnative.dev/docs/textinput#oncontentsizechange
const isMultiline = element.props.multiline === true;
if (isMultiline) {
const contentSize = getTextContentSize(text);
- dispatchEvent(
+ await dispatchEvent(
element,
'contentSizeChange',
EventBuilder.TextInput.contentSizeChange(contentSize),
@@ -55,6 +63,6 @@ export async function paste(
// 4. Exit element
await wait(this.config);
- dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text));
- dispatchEvent(element, 'blur', EventBuilder.Common.blur());
+ await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text));
+ await dispatchEvent(element, 'blur', EventBuilder.Common.blur());
}
diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx
index 9501b94dd..656fc3c0e 100644
--- a/src/user-event/press/__tests__/longPress.real-timers.test.tsx
+++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-native';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames } from '../../../test-utils';
+import { createEventLogger, getEventsNames } from '../../../test-utils/events';
import { userEvent } from '../..';
describe('userEvent.longPress with real timers', () => {
diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx
index 3dfb29142..48fe1f49c 100644
--- a/src/user-event/press/__tests__/longPress.test.tsx
+++ b/src/user-event/press/__tests__/longPress.test.tsx
@@ -3,7 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'rea
import type { ReactTestInstance } from 'react-test-renderer';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames } from '../../../test-utils';
+import { createEventLogger, getEventsNames } from '../../../test-utils/events';
import { userEvent } from '../..';
describe('userEvent.longPress with fake timers', () => {
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 903e353cf..d10954369 100644
--- a/src/user-event/press/__tests__/press.real-timers.test.tsx
+++ b/src/user-event/press/__tests__/press.real-timers.test.tsx
@@ -10,7 +10,7 @@ import {
} from 'react-native';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames } from '../../../test-utils';
+import { createEventLogger, getEventsNames } from '../../../test-utils/events';
import { userEvent } from '../..';
describe('userEvent.press with real timers', () => {
diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx
index a73d9813d..9ab2ef762 100644
--- a/src/user-event/press/__tests__/press.test.tsx
+++ b/src/user-event/press/__tests__/press.test.tsx
@@ -11,7 +11,7 @@ import {
import type { ReactTestInstance } from 'react-test-renderer';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames } from '../../../test-utils';
+import { createEventLogger, getEventsNames } from '../../../test-utils/events';
import { userEvent } from '../..';
describe('userEvent.press with fake timers', () => {
diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts
index e0f432367..e131a4fa5 100644
--- a/src/user-event/press/press.ts
+++ b/src/user-event/press/press.ts
@@ -118,23 +118,23 @@ async function emitDirectPressEvents(
options: BasePressOptions,
) {
await wait(config);
- dispatchEvent(element, 'pressIn', EventBuilder.Common.touch());
+ await dispatchEvent(element, 'pressIn', 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());
+ await dispatchEvent(element, 'longPress', EventBuilder.Common.touch());
}
- dispatchEvent(element, 'pressOut', EventBuilder.Common.touch());
+ await 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());
+ await dispatchEvent(element, 'press', EventBuilder.Common.touch());
}
}
@@ -145,12 +145,12 @@ async function emitPressabilityPressEvents(
) {
await wait(config);
- dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant());
+ await dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant());
const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION;
await wait(config, duration);
- dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease());
+ await 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
diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
index faa7ee49d..e71333cf6 100644
--- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
+++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
[
diff --git a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx
index c7024af70..ea111a058 100644
--- a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx
+++ b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx
@@ -3,8 +3,8 @@ import type { ScrollViewProps } from 'react-native';
import { FlatList, Text, View } from 'react-native';
import { render, screen } from '../../..';
-import type { EventEntry } from '../../../test-utils';
-import { createEventLogger } from '../../../test-utils';
+import type { EventEntry } from '../../../test-utils/events';
+import { createEventLogger } from '../../../test-utils/events';
import { userEvent } from '../..';
const data = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx
index 4ad99587a..29988636b 100644
--- a/src/user-event/scroll/__tests__/scroll-to.test.tsx
+++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx
@@ -3,8 +3,8 @@ import type { ScrollViewProps } from 'react-native';
import { ScrollView, View } from 'react-native';
import { fireEvent, render, screen } from '../../..';
-import type { EventEntry } from '../../../test-utils';
-import { createEventLogger } from '../../../test-utils';
+import type { EventEntry } from '../../../test-utils/events';
+import { createEventLogger } from '../../../test-utils/events';
import { userEvent } from '../..';
function mapEventsToShortForm(events: EventEntry[]) {
diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts
index 08e4534f8..b019e2ba8 100644
--- a/src/user-event/scroll/scroll-to.ts
+++ b/src/user-event/scroll/scroll-to.ts
@@ -50,7 +50,7 @@ export async function scrollTo(
ensureScrollViewDirection(element, options);
- dispatchEvent(
+ await dispatchEvent(
element,
'contentSizeChange',
options.contentSize?.width ?? 0,
@@ -88,7 +88,7 @@ async function emitDragScrollEvents(
}
await wait(config);
- dispatchEvent(
+ await dispatchEvent(
element,
'scrollBeginDrag',
EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions),
@@ -99,12 +99,20 @@ async function emitDragScrollEvents(
// See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events
for (let i = 1; i < scrollSteps.length - 1; i += 1) {
await wait(config);
- dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions));
+ await dispatchEvent(
+ element,
+ 'scroll',
+ EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions),
+ );
}
await wait(config);
const lastStep = scrollSteps.at(-1);
- dispatchEvent(element, 'scrollEndDrag', EventBuilder.ScrollView.scroll(lastStep, scrollOptions));
+ await dispatchEvent(
+ element,
+ 'scrollEndDrag',
+ EventBuilder.ScrollView.scroll(lastStep, scrollOptions),
+ );
}
async function emitMomentumScrollEvents(
@@ -118,7 +126,7 @@ async function emitMomentumScrollEvents(
}
await wait(config);
- dispatchEvent(
+ await dispatchEvent(
element,
'momentumScrollBegin',
EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions),
@@ -129,12 +137,16 @@ async function emitMomentumScrollEvents(
// See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events
for (let i = 1; i < scrollSteps.length; i += 1) {
await wait(config);
- dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions));
+ await dispatchEvent(
+ element,
+ 'scroll',
+ EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions),
+ );
}
await wait(config);
const lastStep = scrollSteps.at(-1);
- dispatchEvent(
+ await dispatchEvent(
element,
'momentumScrollEnd',
EventBuilder.ScrollView.scroll(lastStep, scrollOptions),
diff --git a/src/user-event/type/__tests__/type-managed.test.tsx b/src/user-event/type/__tests__/type-managed.test.tsx
index 26f8f3e99..8f5677843 100644
--- a/src/user-event/type/__tests__/type-managed.test.tsx
+++ b/src/user-event/type/__tests__/type-managed.test.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { TextInput } from 'react-native';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames } from '../../../test-utils';
+import { createEventLogger, getEventsNames } from '../../../test-utils/events';
import { userEvent } from '../..';
beforeEach(() => {
diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx
index c69a41a85..7ce499008 100644
--- a/src/user-event/type/__tests__/type.test.tsx
+++ b/src/user-event/type/__tests__/type.test.tsx
@@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native';
import { TextInput, View } from 'react-native';
import { render, screen } from '../../..';
-import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils';
+import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events';
import { userEvent } from '../..';
beforeEach(() => {
diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts
index 022eb6d31..8607ef879 100644
--- a/src/user-event/type/type.ts
+++ b/src/user-event/type/type.ts
@@ -37,14 +37,14 @@ export async function type(
const keys = parseKeys(text);
if (!options?.skipPress) {
- dispatchEvent(element, 'pressIn', EventBuilder.Common.touch());
+ await dispatchEvent(element, 'pressIn', EventBuilder.Common.touch());
}
- dispatchEvent(element, 'focus', EventBuilder.Common.focus());
+ await dispatchEvent(element, 'focus', EventBuilder.Common.focus());
if (!options?.skipPress) {
await wait(this.config);
- dispatchEvent(element, 'pressOut', EventBuilder.Common.touch());
+ await dispatchEvent(element, 'pressOut', EventBuilder.Common.touch());
}
let currentText = getTextInputValue(element);
@@ -66,12 +66,12 @@ export async function type(
await wait(this.config);
if (options?.submitEditing) {
- dispatchEvent(element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText));
+ await dispatchEvent(element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText));
}
if (!options?.skipBlur) {
- dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(finalText));
- dispatchEvent(element, 'blur', EventBuilder.Common.blur());
+ await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(finalText));
+ await dispatchEvent(element, 'blur', EventBuilder.Common.blur());
}
}
@@ -89,7 +89,7 @@ export async function emitTypingEvents(
const isMultiline = element.props.multiline === true;
await wait(config);
- dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key));
+ await dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key));
// Platform difference (based on experiments):
// - iOS and RN Web: TextInput emits only `keyPress` event when max length has been reached
@@ -99,20 +99,24 @@ export async function emitTypingEvents(
}
nativeState.valueForElement.set(element, text);
- dispatchEvent(element, 'change', EventBuilder.TextInput.change(text));
- dispatchEvent(element, 'changeText', text);
+ await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text));
+ await dispatchEvent(element, 'changeText', text);
const selectionRange = {
start: text.length,
end: text.length,
};
- dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange));
+ await dispatchEvent(
+ element,
+ 'selectionChange',
+ EventBuilder.TextInput.selectionChange(selectionRange),
+ );
// According to the docs only multiline TextInput emits contentSizeChange event
// @see: https://reactnative.dev/docs/textinput#oncontentsizechange
if (isMultiline) {
const contentSize = getTextContentSize(text);
- dispatchEvent(
+ await dispatchEvent(
element,
'contentSizeChange',
EventBuilder.TextInput.contentSizeChange(contentSize),
diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx
index 491e83f1a..573b338d6 100644
--- a/src/user-event/utils/__tests__/dispatch-event.test.tsx
+++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx
@@ -8,15 +8,15 @@ import { dispatchEvent } from '../dispatch-event';
const TOUCH_EVENT = EventBuilder.Common.touch();
describe('dispatchEvent', () => {
- it('does dispatch event', () => {
+ it('does dispatch event', async () => {
const onPress = jest.fn();
render();
- dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
+ await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
expect(onPress).toHaveBeenCalledTimes(1);
});
- it('does not dispatch event to parent host component', () => {
+ it('does not dispatch event to parent host component', async () => {
const onPressParent = jest.fn();
render(
@@ -24,17 +24,19 @@ describe('dispatchEvent', () => {
,
);
- dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
+ await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
expect(onPressParent).not.toHaveBeenCalled();
});
- it('does NOT throw if no handler found', () => {
+ it('does NOT throw if no handler found', async () => {
render(
,
);
- expect(() => dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT)).not.toThrow();
+ await expect(
+ dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT),
+ ).resolves.not.toThrow();
});
});
diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts
index 3f04fb31d..161d4cfa7 100644
--- a/src/user-event/utils/dispatch-event.ts
+++ b/src/user-event/utils/dispatch-event.ts
@@ -11,7 +11,11 @@ import { isElementMounted } from '../../helpers/component-tree';
* @param eventName name of the event
* @param event event payload(s)
*/
-export function dispatchEvent(element: ReactTestInstance, eventName: string, ...event: unknown[]) {
+export async function dispatchEvent(
+ element: ReactTestInstance,
+ eventName: string,
+ ...event: unknown[]
+) {
if (!isElementMounted(element)) {
return;
}
@@ -21,8 +25,8 @@ export function dispatchEvent(element: ReactTestInstance, eventName: string, ...
return;
}
- // This will be called synchronously.
- void act(() => {
+ // eslint-disable-next-line require-await
+ await act(async () => {
handler(...event);
});
}
diff --git a/src/wait-for.ts b/src/wait-for.ts
index ad8abbb7c..1bf96e68c 100644
--- a/src/wait-for.ts
+++ b/src/wait-for.ts
@@ -1,4 +1,5 @@
/* globals jest */
+import act from './act';
import { getConfig } from './config';
import { flushMicroTasks } from './flush-micro-tasks';
import { copyStackTrace, ErrorWithStack } from './helpers/errors';
@@ -69,7 +70,7 @@ function waitForInternal(
// third party code that's setting up recursive timers so rapidly that
// the user's timer's don't get a chance to resolve. So we'll advance
// by an interval instead. (We have a test for this case).
- jest.advanceTimersByTime(interval);
+ await act(async () => await jest.advanceTimersByTime(interval));
// It's really important that checkExpectation is run *before* we flush
// in-flight promises. To be honest, I'm not sure why, and I can't quite
diff --git a/website/docs/13.x/docs/api/events/fire-event.mdx b/website/docs/13.x/docs/api/events/fire-event.mdx
index 7f072d48b..cd6d7c5b2 100644
--- a/website/docs/13.x/docs/api/events/fire-event.mdx
+++ b/website/docs/13.x/docs/api/events/fire-event.mdx
@@ -32,7 +32,7 @@ test('fire changeText event', () => {
```
:::note
-Please note that from version `7.0` `fireEvent` performs checks that should prevent events firing on disabled elements.
+`fireEvent` performs checks that should prevent events firing on disabled elements.
:::
An example using `fireEvent` with native events that aren't already aliased by the `fireEvent` api.
@@ -156,3 +156,58 @@ fireEvent.scroll(screen.getByText('scroll-view'), eventData);
Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEvent.scroll` for `ScrollView`, `FlatList`, and `SectionList` components. User Event provides a more realistic event simulation based on React Native runtime behavior.
:::
+
+
+## `fireEventAsync`
+
+:::info RNTL minimal version
+
+This API requires RNTL v13.3.0 or later.
+
+:::
+
+
+```ts
+async function fireEventAsync(element: ReactTestInstance, eventName: string, ...data: unknown[]): Promise;
+```
+
+The `fireEventAsync` function is the async version of `fireEvent` designed for working with React 19 and React Suspense. It wraps event handler execution in async `act()`, making it suitable for event handlers that trigger suspense boundaries or other async behavior.
+
+```jsx
+import { renderAsync, screen, fireEventAsync } from '@testing-library/react-native';
+
+test('async fire event test', async () => {
+ await renderAsync();
+
+ // Use fireEventAsync when event handlers have async behavior
+ await fireEventAsync(screen.getByText('Async Button'), 'press');
+
+ expect(screen.getByText('Async operation completed')).toBeOnTheScreen();
+});
+```
+
+Like `fireEvent`, `fireEventAsync` also provides convenience methods for common events: `fireEventAsync.press`, `fireEventAsync.changeText`, and `fireEventAsync.scroll`.
+
+### `fireEventAsync.press` {#async-press}
+
+```
+fireEventAsync.press: (element: ReactTestInstance, ...data: Array) => Promise
+```
+
+Async version of `fireEvent.press` designed for React 19 and React Suspense. Use when press event handlers trigger suspense boundaries or other async behavior.
+
+### `fireEventAsync.changeText` {#async-change-text}
+
+```
+fireEventAsync.changeText: (element: ReactTestInstance, ...data: Array) => Promise
+```
+
+Async version of `fireEvent.changeText` designed for React 19 and React Suspense. Use when changeText event handlers trigger suspense boundaries or other async behavior.
+
+### `fireEventAsync.scroll` {#async-scroll}
+
+```
+fireEventAsync.scroll: (element: ReactTestInstance, ...data: Array) => Promise
+```
+
+Async version of `fireEvent.scroll` designed for React 19 and React Suspense. Use when scroll event handlers trigger suspense boundaries or other async behavior.
diff --git a/website/docs/13.x/docs/api/render.mdx b/website/docs/13.x/docs/api/render.mdx
index 5022b87b8..9dc01942d 100644
--- a/website/docs/13.x/docs/api/render.mdx
+++ b/website/docs/13.x/docs/api/render.mdx
@@ -64,3 +64,43 @@ React Test Renderer does not enforce this check; hence, by default, React Native
The `render` function returns the same queries and utilities as the [`screen`](docs/api/screen) object. We recommended using the `screen` object as more developer-friendly way.
See [this article](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-screen) from Kent C. Dodds for more details.
+
+## `renderAsync` function
+
+:::info RNTL minimal version
+
+This API requires RNTL v13.3.0 or later.
+
+:::
+
+```jsx
+async function renderAsync(
+ component: React.Element,
+ options?: RenderAsyncOptions
+): Promise
+```
+
+The `renderAsync` function is the async version of `render` designed for working with React 19 and React Suspense. It allows components to be properly rendered when they contain suspense boundaries or async behavior that needs to complete before the render result is returned.
+
+```jsx
+import { renderAsync, screen } from '@testing-library/react-native';
+
+test('async component test', async () => {
+ await renderAsync();
+ expect(screen.getAllByRole('button', { name: 'start' })).toBeOnTheScreen();
+});
+```
+
+### Options
+
+`renderAsync` accepts the same options as `render`.
+
+### Result
+
+The `renderAsync` function returns a promise that resolves to the same queries and utilities as the [`screen`](docs/api/screen) object. We recommend using the `screen` object for queries and the lifecycle methods from the render result when needed.
+
+:::warning Async lifecycle methods
+
+When using `renderAsync`, you have to use correspodning lifecycle methods: `rerenderAsync` and `unmountAsync` instead of their sync versions.
+
+:::
diff --git a/website/docs/13.x/docs/api/screen.mdx b/website/docs/13.x/docs/api/screen.mdx
index 73e99e83a..06b448dff 100644
--- a/website/docs/13.x/docs/api/screen.mdx
+++ b/website/docs/13.x/docs/api/screen.mdx
@@ -41,6 +41,35 @@ function rerender(element: React.Element): void;
Re-render the in-memory tree with a new root element. This simulates a React update render at the root. If the new element has the same type (and `key`) as the previous element, the tree will be updated; otherwise, it will re-mount a new tree, in both cases triggering the appropriate lifecycle events.
+### `rerenderAsync`
+
+_Also available under `updateAsync` alias_
+
+:::info RNTL minimal version
+
+This API requires RNTL v13.3.0 or later.
+
+:::
+
+```ts
+function rerenderAsync(element: React.Element): Promise;
+```
+
+Async versions of `rerender` designed for working with React 19 and React Suspense. These methods wait for async operations to complete during re-rendering, making them suitable for components that use suspense boundaries or other async behavior.
+
+```jsx
+import { renderAsync, screen } from '@testing-library/react-native';
+
+test('async rerender test', async () => {
+ await renderAsync();
+
+ // Use async rerender when component has suspense or async behavior
+ await screen.rerenderAsync();
+
+ expect(screen.getByText('updated')).toBeOnTheScreen();
+});
+```
+
### `unmount`
```ts
@@ -50,7 +79,29 @@ function unmount(): void;
Unmount the in-memory tree, triggering the appropriate lifecycle events.
:::note
+
Usually you should not need to call `unmount` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine).
+
+:::
+
+### `unmountAsync`
+
+:::info RNTL minimal version
+
+This API requires RNTL v13.3.0 or later.
+
+:::
+
+```ts
+function unmountAsync(): Promise;
+```
+
+Async version of `unmount` designed for working with React 19 and React Suspense. This method waits for async cleanup operations to complete during unmounting, making it suitable for components that have async cleanup behavior.
+
+:::note
+
+Usually you should not need to call `unmountAsync` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine).
+
:::
### `debug`