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