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/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx
new file mode 100644
index 000000000..40089b02d
--- /dev/null
+++ b/src/__tests__/fire-event-async.test.tsx
@@ -0,0 +1,661 @@
+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();
+ const result = render(
+
+ Test
+ ,
+ );
+
+ const element = screen.getByText('Test');
+
+ // Unmount the component
+ result.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..c0c7c6b47 100644
--- a/src/__tests__/fire-event.test.tsx
+++ b/src/__tests__/fire-event.test.tsx
@@ -553,3 +553,47 @@ describe('native events', () => {
expect(onMomentumScrollEndSpy).toHaveBeenCalled();
});
});
+
+test('should handle unmounted elements gracefully', () => {
+ const onPress = jest.fn();
+ const result = render(
+
+ Test
+ ,
+ );
+
+ const element = screen.getByText('Test');
+
+ // Unmount the component
+ result.unmount();
+
+ // Firing event on unmounted element should not crash
+ fireEvent.press(element);
+ expect(onPress).not.toHaveBeenCalled();
+});
+
+test('should handle invalid scroll event data gracefully', () => {
+ const onScrollSpy = jest.fn();
+ render();
+
+ const scrollView = screen.getByTestId('scroll-view');
+
+ // Test with malformed event data that would cause an error in tryGetContentOffset
+ fireEvent.scroll(scrollView, { malformed: 'data' });
+ expect(onScrollSpy).toHaveBeenCalled();
+});
+
+test('should handle scroll event with invalid contentOffset', () => {
+ const onScrollSpy = jest.fn();
+ render();
+
+ const scrollView = screen.getByTestId('scroll-view');
+
+ // Test with event data that has invalid contentOffset structure
+ fireEvent.scroll(scrollView, {
+ nativeEvent: {
+ contentOffset: { x: 'invalid', y: null },
+ },
+ });
+ expect(onScrollSpy).toHaveBeenCalled();
+});
diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx
new file mode 100644
index 000000000..ae7aaca34
--- /dev/null
+++ b/src/__tests__/render-async.test.tsx
@@ -0,0 +1,143 @@
+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('renderAsync supports legacy rendering option', async () => {
+ await renderAsync(, { concurrentRoot: false });
+ expect(screen.root).toBeOnTheScreen();
+});
+
+test('update function updates component synchronously', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ result.update();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('updateAsync function updates component asynchronously', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ await result.updateAsync();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('rerender is an alias for update', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ result.rerender();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('rerenderAsync is an alias for updateAsync', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ await result.rerenderAsync();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('unmount function unmounts component synchronously', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ result.unmount();
+
+ expect(fn).toHaveBeenCalled();
+});
+
+test('unmountAsync function unmounts component asynchronously', async () => {
+ const fn = jest.fn();
+ const result = await renderAsync();
+
+ await result.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.test.tsx b/src/__tests__/render.test.tsx
index 6aa0769dd..031e247ba 100644
--- a/src/__tests__/render.test.tsx
+++ b/src/__tests__/render.test.tsx
@@ -243,3 +243,30 @@ test('supports concurrent rendering', () => {
render(, { concurrentRoot: true });
expect(screen.root).toBeOnTheScreen();
});
+
+test('updateAsync updates the component asynchronously', async () => {
+ const fn = jest.fn();
+ const result = render();
+
+ await result.updateAsync();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+});
+
+test('rerenderAsync is an alias for updateAsync', async () => {
+ const fn = jest.fn();
+ const result = render();
+
+ await result.rerenderAsync();
+
+ 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..6e5feb047
--- /dev/null
+++ b/src/__tests__/suspense-fake-timers.test.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { act, renderAsync, screen } from '..';
+
+jest.useFakeTimers();
+
+const testGateReact19 = React.version.startsWith('19.') ? test : test.skip;
+
+function Suspending({ promise }: { promise: Promise }) {
+ 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();
+});
diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx
new file mode 100644
index 000000000..50a72353a
--- /dev/null
+++ b/src/__tests__/suspense.test.tsx
@@ -0,0 +1,58 @@
+import * as React from 'react';
+import { Text, View } from 'react-native';
+
+import { act, renderAsync, screen } from '..';
+
+const testGateReact19 = React.version.startsWith('19.') ? test : test.skip;
+
+function Suspending({ promise }: { promise: Promise }) {
+ 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();
+});
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..d191f3298
--- /dev/null
+++ b/src/render-async.tsx
@@ -0,0 +1,136 @@
+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 { 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 update = function (component: React.ReactElement) {
+ void act(() => {
+ renderer.update(wrap(component));
+ });
+ };
+ const updateAsync = async function (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,
+ updateAsync,
+ rerender: update, // alias for `update`
+ rerenderAsync: updateAsync, // alias for `update`
+ 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..f08a379f7 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 update = function (component: React.ReactElement) {
+ void act(() => {
+ renderer.update(wrap(component));
+ });
+ };
+ const updateAsync = async function (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,
- unmount,
+ updateAsync,
rerender: update, // alias for `update`
+ rerenderAsync: updateAsync, // alias for `update`
+ unmount,
+ 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..14173549f 100644
--- a/src/screen.ts
+++ b/src/screen.ts
@@ -26,8 +26,11 @@ const defaultScreen: Screen = {
},
debug: notImplementedDebug,
update: notImplemented,
+ updateAsync: notImplemented,
unmount: notImplemented,
+ unmountAsync: notImplemented,
rerender: notImplemented,
+ rerenderAsync: notImplemented,
toJSON: notImplemented,
getByLabelText: notImplemented,
getAllByLabelText: notImplemented,
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/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/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/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