From 5ffc46d443f765cf6baf070996ad82a0d3b68cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 4 Jan 2026 17:16:11 +0100 Subject: [PATCH 1/5] add gemini cli --- .gemini/settings.json | 5 +++++ AGENTS.md | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .gemini/settings.json create mode 100644 AGENTS.md diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..523d3da35 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "contextFileName": "AGENTS.md" + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4a1d5c437 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Gemini Code Assistant Context + +This document provides context for the Gemini code assistant to understand the `@testing-library/react-native` project. + +## Project Overview + +`@testing-library/react-native` (RNTL) provides a set of utilities for testing React Native components. It is designed to facilitate writing tests that resemble how users interact with the application, avoiding implementation details. + +* **Core Principle:** "The more your tests resemble the way your software is used, the more confidence they can give you." +* **Tech Stack:** TypeScript, React Native, Jest. +* **Architecture:** The library simulates the React Native runtime on top of `universal-test-renderer`. + +## Building and Running + +The project uses `yarn` for dependency management and script execution. + +* **Installation:** `yarn install` +* **Run Tests:** `yarn test` (Runs Jest) +* **Run Tests (CI):** `yarn test:ci` (Runs Jest with worker limits) +* **Lint Code:** `yarn lint` (Runs ESLint on `src`) +* **Type Check:** `yarn typecheck` (Runs TypeScript compiler) +* **Format Check:** `yarn prettier` +* **Validate All:** `yarn validate` (Runs Prettier, ESLint, Typecheck, and Tests in sequence) +* **Build Project:** `yarn build` (Cleans, builds JS with Babel, builds TS types, and copies Flow types) + +## Development Conventions + +* **Code Style:** + * **Linting:** ESLint is configured with `@callstack/eslint-config` and `typescript-eslint`. It enforces strict rules, including `no-console` and consistent type imports. + * **Formatting:** Prettier is used for code formatting (single quotes, trailing commas). + * **Imports:** Sorted using `eslint-plugin-simple-import-sort`. + +* **Testing:** + * **Framework:** Jest with `react-native` preset. + * **Location:** Tests are located within `src`, typically in `__tests__` directories or co-located. + * **Setup:** `jest-setup.ts` configures the test environment. `src/index.ts` automatically configures cleanup after each test unless skipped. + * **Coverage:** Collected from `src`, excluding tests. + +* **Commits & Releases:** + * **Commits:** Follow the **Conventional Commits** specification (e.g., `fix:`, `feat:`, `chore:`). This is enforced and used for changelog generation. + * **Releases:** Managed via `release-it`. + +* **File Structure:** + * `src/`: Source code. + * `src/pure.ts`: Core logic without side effects (no auto-cleanup). + * `src/index.ts`: Main entry point, re-exports `pure` and adds side effects (auto-cleanup). + * `examples/`: Example React Native applications using the library. + * `website/`: Documentation website. From 71ea25127490e3d85f7fc5d3475b1b4cd778b0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 4 Jan 2026 18:24:27 +0100 Subject: [PATCH 2/5] refactor: fireEventAsync --- src/__tests__/act.test.tsx | 4 +- .../deprecated-fire-event-sync.test.tsx | 571 +++++++++++++++ src/__tests__/fire-event-async.test.tsx | 659 ------------------ src/__tests__/fire-event-textInput.test.tsx | 46 +- src/__tests__/fire-event.test.tsx | 450 +++++++----- .../react-native-gesture-handler.test.tsx | 10 +- src/__tests__/render-debug.test.tsx | 4 +- src/__tests__/render.test.tsx | 4 +- .../wait-for-element-to-be-removed.test.tsx | 12 +- src/fire-event.ts | 32 +- src/pure.ts | 2 +- src/queries/__tests__/display-value.test.tsx | 4 +- .../scroll/__tests__/scroll-to.test.tsx | 2 +- .../14.x/docs/advanced/understanding-act.mdx | 2 +- .../docs/14.x/docs/api/events/fire-event.mdx | 101 +-- website/docs/14.x/docs/guides/react-19.mdx | 2 +- website/docs/14.x/docs/migration/v14.mdx | 57 ++ 17 files changed, 975 insertions(+), 987 deletions(-) create mode 100644 src/__tests__/deprecated-fire-event-sync.test.tsx delete mode 100644 src/__tests__/fire-event-async.test.tsx diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx index f3b373df4..036b80a14 100644 --- a/src/__tests__/act.test.tsx +++ b/src/__tests__/act.test.tsx @@ -31,12 +31,12 @@ test('rerender should trigger useEffect', () => { expect(effectCallback).toHaveBeenCalledTimes(2); }); -test('fireEvent should trigger useState', () => { +test('fireEvent should trigger useState', async () => { render(); const counter = screen.getByText(/Total count/i); expect(counter.props.children).toEqual('Total count: 0'); - fireEvent.press(counter); + await fireEvent.press(counter); expect(counter.props.children).toEqual('Total count: 1'); }); diff --git a/src/__tests__/deprecated-fire-event-sync.test.tsx b/src/__tests__/deprecated-fire-event-sync.test.tsx new file mode 100644 index 000000000..2793bb6f7 --- /dev/null +++ b/src/__tests__/deprecated-fire-event-sync.test.tsx @@ -0,0 +1,571 @@ +import * as React from 'react'; +import { + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { deprecated_fireEventSync, render, screen } 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('deprecated_fireEventSync', () => { + test('should invoke specified event', () => { + const onPressMock = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByText('Press me'), 'press'); + + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should invoke specified event on parent element', () => { + const onPressMock = jest.fn(); + const text = 'New press text'; + render(); + + deprecated_fireEventSync(screen.getByText(text), 'press'); + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should invoke event with custom name', () => { + const handlerMock = jest.fn(); + const EVENT_DATA = 'event data'; + + render( + + + , + ); + + deprecated_fireEventSync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + + expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); + }); +}); + +test('deprecated_fireEventSync.press', () => { + const onPressMock = jest.fn(); + const text = 'Fireevent press'; + const eventData = { + nativeEvent: { + pageX: 20, + pageY: 30, + }, + }; + render(); + + deprecated_fireEventSync.press(screen.getByText(text), eventData); + + expect(onPressMock).toHaveBeenCalledWith(eventData); +}); + +test('deprecated_fireEventSync.scroll', () => { + const onScrollMock = jest.fn(); + const eventData = { + nativeEvent: { + contentOffset: { + y: 200, + }, + }, + }; + + render( + + XD + , + ); + + deprecated_fireEventSync.scroll(screen.getByText('XD'), eventData); + + expect(onScrollMock).toHaveBeenCalledWith(eventData); +}); + +test('deprecated_fireEventSync.changeText', () => { + const onChangeTextMock = jest.fn(); + + render( + + + , + ); + + const input = screen.getByPlaceholderText('Customer placeholder'); + deprecated_fireEventSync.changeText(input, 'content'); + expect(onChangeTextMock).toHaveBeenCalledWith('content'); +}); + +it('sets native state value for unmanaged text inputs', () => { + render(); + + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + deprecated_fireEventSync.changeText(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); +}); + +test('custom component with custom event name', () => { + const handlePress = jest.fn(); + + render(); + + deprecated_fireEventSync(screen.getByText('Custom component'), 'handlePress'); + + expect(handlePress).toHaveBeenCalled(); +}); + +test('event with multiple handler parameters', () => { + const handlePress = jest.fn(); + + render(); + + deprecated_fireEventSync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); + + expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); +}); + +test('should not fire on disabled TouchableOpacity', () => { + const handlePress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire on disabled Pressable', () => { + const handlePress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in props', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in styles', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in styles array', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="box-only" in props', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire inside View with pointerEvents="box-none" in props', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="box-none" in styles', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="auto" in props', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="auto" in styles', () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should not fire deeply inside View with pointerEvents="box-only" in props', () => { + const onPress = jest.fn(); + render( + + + + Trigger + + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire deeply inside View with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + render( + + + + Trigger + + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Trigger')); + deprecated_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire non-pointer events inside View with pointerEvents="box-none" in props', () => { + const onTouchStart = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('view'), 'touchStart'); + expect(onTouchStart).toHaveBeenCalled(); +}); + +test('should fire non-pointer events inside View with pointerEvents="box-none" in styles', () => { + const onTouchStart = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('view'), 'touchStart'); + expect(onTouchStart).toHaveBeenCalled(); +}); + +test('should fire non-touch events inside View with pointerEvents="box-none" in props', () => { + const onLayout = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); +}); + +test('should fire non-touch events inside View with pointerEvents="box-none" in styles', () => { + const onLayout = jest.fn(); + render(); + + deprecated_fireEventSync(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" in props', () => { + const onPress = jest.fn(); + render(); + + deprecated_fireEventSync.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); +}); + +test('should fire on Pressable with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + render(); + + deprecated_fireEventSync.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); +}); + +test('should pass event up on disabled TouchableOpacity', () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + render( + + + Inner Trigger + + , + ); + + deprecated_fireEventSync.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); +}); + +test('should pass event up on disabled Pressable', () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + render( + + + Inner Trigger + + , + ); + + deprecated_fireEventSync.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', () => { + const handlePress = jest.fn(); + render(); + + deprecated_fireEventSync.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', () => { + const handlePress = jest.fn(); + + render( + + + , + ); + + deprecated_fireEventSync.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', () => { + const handleDrag = jest.fn(); + + render(); + + deprecated_fireEventSync(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }); + expect(handleDrag).toHaveBeenCalled(); +}); + +// Those events ideally should be triggered through `deprecated_fireEventSync.scroll`, but they are handled at the +// native level, so we need to support manually triggering them +describe('native events', () => { + test('triggers onScrollBeginDrag', () => { + const onScrollBeginDragSpy = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('test-id'), 'onScrollBeginDrag'); + expect(onScrollBeginDragSpy).toHaveBeenCalled(); + }); + + test('triggers onScrollEndDrag', () => { + const onScrollEndDragSpy = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('test-id'), 'onScrollEndDrag'); + expect(onScrollEndDragSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollBegin', () => { + const onMomentumScrollBeginSpy = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); + expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollEnd', () => { + const onMomentumScrollEndSpy = jest.fn(); + render(); + + deprecated_fireEventSync(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); + expect(onMomentumScrollEndSpy).toHaveBeenCalled(); + }); +}); + +test('should handle unmounted elements gracefully', () => { + const onPress = jest.fn(); + render( + + Test + , + ); + + const element = screen.getByText('Test'); + screen.unmount(); + + // Firing event on unmounted element should not crash + deprecated_fireEventSync.press(element); + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx deleted file mode 100644 index 49e96be94..000000000 --- a/src/__tests__/fire-event-async.test.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import * as React from 'react'; -import { - PanResponder, - Pressable, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; - -import { fireEventAsync, render, screen, waitFor } from '..'; - -type OnPressComponentProps = { - onPress: () => void; - text: string; -}; -const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( - - - {text} - - -); - -type CustomEventComponentProps = { - onCustomEvent: () => void; -}; -const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => ( - - Custom event component - -); - -type MyCustomButtonProps = { - handlePress: () => void; - text: string; -}; -const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => ( - -); - -type CustomEventComponentWithCustomNameProps = { - handlePress: () => void; -}; -const CustomEventComponentWithCustomName = ({ - handlePress, -}: CustomEventComponentWithCustomNameProps) => ( - -); - -describe('fireEventAsync', () => { - test('should invoke specified event', async () => { - const onPressMock = jest.fn(); - render(); - - await fireEventAsync(screen.getByText('Press me'), 'press'); - - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should invoke specified event on parent element', async () => { - const onPressMock = jest.fn(); - const text = 'New press text'; - render(); - - await fireEventAsync(screen.getByText(text), 'press'); - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should invoke event with custom name', async () => { - const handlerMock = jest.fn(); - const EVENT_DATA = 'event data'; - - render( - - - , - ); - - await fireEventAsync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); - - expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); - }); -}); - -test('fireEventAsync.press', async () => { - const onPressMock = jest.fn(); - const text = 'Fireevent press'; - const eventData = { - nativeEvent: { - pageX: 20, - pageY: 30, - }, - }; - render(); - - await fireEventAsync.press(screen.getByText(text), eventData); - - expect(onPressMock).toHaveBeenCalledWith(eventData); -}); - -test('fireEventAsync.scroll', async () => { - const onScrollMock = jest.fn(); - const eventData = { - nativeEvent: { - contentOffset: { - y: 200, - }, - }, - }; - - render( - - XD - , - ); - - await fireEventAsync.scroll(screen.getByText('XD'), eventData); - - expect(onScrollMock).toHaveBeenCalledWith(eventData); -}); - -test('fireEventAsync.changeText', async () => { - const onChangeTextMock = jest.fn(); - - render( - - - , - ); - - const input = screen.getByPlaceholderText('Customer placeholder'); - await fireEventAsync.changeText(input, 'content'); - expect(onChangeTextMock).toHaveBeenCalledWith('content'); -}); - -it('sets native state value for unmanaged text inputs', async () => { - render(); - - const input = screen.getByTestId('input'); - expect(input).toHaveDisplayValue(''); - - await fireEventAsync.changeText(input, 'abc'); - expect(input).toHaveDisplayValue('abc'); -}); - -test('custom component with custom event name', async () => { - const handlePress = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Custom component'), 'handlePress'); - - expect(handlePress).toHaveBeenCalled(); -}); - -test('event with multiple handler parameters', async () => { - const handlePress = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); - - expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); -}); - -test('should not fire on disabled TouchableOpacity', async () => { - const handlePress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -test('should not fire on disabled Pressable', async () => { - const handlePress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="none"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should fire inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should fire inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should not fire deeply inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - render( - - - - Trigger - - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { - const onTouchStart = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('view'), 'touchStart'); - expect(onTouchStart).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); -}); - -// This test if pointerEvents="box-only" on composite `Pressable` is blocking -// the 'press' event on host View rendered by pressable. -test('should fire on Pressable with pointerEvents="box-only', async () => { - const onPress = jest.fn(); - render(); - - await fireEventAsync.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); -}); - -test('should pass event up on disabled TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - render( - - - Inner Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -test('should pass event up on disabled Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - render( - - - Inner Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -type TestComponentProps = { - onPress: () => void; - disabled?: boolean; -}; -const TestComponent = ({ onPress }: TestComponentProps) => { - return ( - - Trigger Test - - ); -}; - -test('is not fooled by non-native disabled prop', async () => { - const handlePress = jest.fn(); - render(); - - await fireEventAsync.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); -}); - -type TestChildTouchableComponentProps = { - onPress: () => void; - someProp: boolean; -}; - -function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) { - return ( - - - Trigger - - - ); -} - -test('is not fooled by non-responder wrapping host elements', async () => { - const handlePress = jest.fn(); - - render( - - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -type TestDraggableComponentProps = { onDrag: () => void }; - -function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: (_evt, _gestureState) => true, - onPanResponderMove: onDrag, - }).panHandlers; - - return ( - - Trigger - - ); -} - -test('has only onMove', async () => { - const handleDrag = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, - }); - expect(handleDrag).toHaveBeenCalled(); -}); - -// Those events ideally should be triggered through `fireEventAsync.scroll`, but they are handled at the -// native level, so we need to support manually triggering them -describe('native events', () => { - test('triggers onScrollBeginDrag', async () => { - const onScrollBeginDragSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onScrollBeginDrag'); - expect(onScrollBeginDragSpy).toHaveBeenCalled(); - }); - - test('triggers onScrollEndDrag', async () => { - const onScrollEndDragSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onScrollEndDrag'); - expect(onScrollEndDragSpy).toHaveBeenCalled(); - }); - - test('triggers onMomentumScrollBegin', async () => { - const onMomentumScrollBeginSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); - expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); - }); - - test('triggers onMomentumScrollEnd', async () => { - const onMomentumScrollEndSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); - expect(onMomentumScrollEndSpy).toHaveBeenCalled(); - }); -}); - -describe('React.Suspense integration', () => { - let mockPromise: Promise; - let resolveMockPromise: (value: string) => void; - - beforeEach(() => { - mockPromise = new Promise((resolve) => { - resolveMockPromise = resolve; - }); - }); - - type AsyncComponentProps = { - onPress: () => void; - shouldSuspend: boolean; - }; - - function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) { - if (shouldSuspend) { - throw mockPromise; - } - - return ( - - Async Component Loaded - - ); - } - - function SuspenseWrapper({ children }: { children: React.ReactNode }) { - return Loading...}>{children}; - } - - test('should handle events after Suspense resolves', async () => { - const onPressMock = jest.fn(); - - render( - - - , - ); - - // Initially shows fallback - expect(screen.getByText('Loading...')).toBeTruthy(); - - // Resolve the promise - resolveMockPromise('loaded'); - await waitFor(() => { - screen.rerender( - - - , - ); - }); - - // Component should be loaded now - await waitFor(() => { - expect(screen.getByText('Async Component Loaded')).toBeTruthy(); - }); - - // fireEventAsync should work on the resolved component - await fireEventAsync.press(screen.getByText('Async Component Loaded')); - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should handle events on Suspense fallback components', async () => { - const fallbackPressMock = jest.fn(); - - function InteractiveFallback() { - return ( - - Loading with button... - - ); - } - - render( - }> - - , - ); - - // Should be able to interact with fallback - expect(screen.getByText('Loading with button...')).toBeTruthy(); - - await fireEventAsync.press(screen.getByText('Loading with button...')); - expect(fallbackPressMock).toHaveBeenCalled(); - }); - - test('should work with nested Suspense boundaries', async () => { - const outerPressMock = jest.fn(); - const innerPressMock = jest.fn(); - - type NestedAsyncProps = { - onPress: () => void; - shouldSuspend: boolean; - level: string; - }; - - function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) { - if (shouldSuspend) { - throw mockPromise; - } - - return ( - - {level} Component Loaded - - ); - } - - const { rerender } = render( - Outer Loading...}> - - Inner Loading...}> - - - , - ); - - // Outer component should be loaded, inner should show fallback - expect(screen.getByText('Outer Component Loaded')).toBeTruthy(); - expect(screen.getByText('Inner Loading...')).toBeTruthy(); - - // Should be able to interact with outer component - await fireEventAsync.press(screen.getByText('Outer Component Loaded')); - expect(outerPressMock).toHaveBeenCalled(); - - // Resolve inner component - resolveMockPromise('inner-loaded'); - await waitFor(() => { - rerender( - Outer Loading...}> - - Inner Loading...}> - - - , - ); - }); - - // Both components should be loaded now - await waitFor(() => { - expect(screen.getByText('Inner Component Loaded')).toBeTruthy(); - }); - - // Should be able to interact with inner component - await fireEventAsync.press(screen.getByText('Inner Component Loaded')); - expect(innerPressMock).toHaveBeenCalled(); - }); - - test('should work when events cause components to suspend', async () => { - const onPressMock = jest.fn(); - let shouldSuspend = false; - - function DataComponent() { - if (shouldSuspend) { - throw mockPromise; // This will cause suspense - } - return Data loaded; - } - - function ButtonComponent() { - return ( - { - onPressMock(); - shouldSuspend = true; // This will cause DataComponent to suspend on next render - }} - > - Load Data - - ); - } - - render( - - - Loading data...}> - - - , - ); - - // Initially data is loaded - expect(screen.getByText('Data loaded')).toBeTruthy(); - - // Click button - this triggers the state change that will cause suspension - await fireEventAsync.press(screen.getByText('Load Data')); - expect(onPressMock).toHaveBeenCalled(); - - // Rerender - now DataComponent should suspend - screen.rerender( - - - Loading data...}> - - - , - ); - - // Should show loading fallback - expect(screen.getByText('Loading data...')).toBeTruthy(); - }); -}); - -test('should handle unmounted elements gracefully in async mode', async () => { - const onPress = jest.fn(); - render( - - Test - , - ); - - const element = screen.getByText('Test'); - screen.unmount(); - - // Firing async event on unmounted element should not crash - await fireEventAsync.press(element); - expect(onPress).not.toHaveBeenCalled(); -}); diff --git a/src/__tests__/fire-event-textInput.test.tsx b/src/__tests__/fire-event-textInput.test.tsx index 7851809e1..9e9f2ccb9 100644 --- a/src/__tests__/fire-event-textInput.test.tsx +++ b/src/__tests__/fire-event-textInput.test.tsx @@ -14,7 +14,7 @@ function DoubleWrappedTextInput(props: TextInputProps) { const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; -test('should fire only non-touch-related events on non-editable TextInput', () => { +test('should fire only non-touch-related events on non-editable TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -32,10 +32,10 @@ test('should fire only non-touch-related events on non-editable TextInput', () = ); const subject = screen.getByTestId('subject'); - fireEvent(subject, 'focus'); - fireEvent.changeText(subject, 'Text'); - fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'focus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'layout', layoutEvent); expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); @@ -43,7 +43,7 @@ test('should fire only non-touch-related events on non-editable TextInput', () = expect(onLayout).toHaveBeenCalledWith(layoutEvent); }); -test('should fire only non-touch-related events on non-editable TextInput with nested Text', () => { +test('should fire only non-touch-related events on non-editable TextInput with nested Text', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -63,13 +63,13 @@ test('should fire only non-touch-related events on non-editable TextInput with n ); const subject = screen.getByText('Nested Text'); - fireEvent(subject, 'focus'); - fireEvent(subject, 'onFocus'); - fireEvent.changeText(subject, 'Text'); - fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - fireEvent(subject, 'onSubmitEditing', { nativeEvent: { text: 'Text' } }); - fireEvent(subject, 'layout', layoutEvent); - fireEvent(subject, 'onLayout', layoutEvent); + await fireEvent(subject, 'focus'); + await fireEvent(subject, 'onFocus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'onSubmitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'onLayout', layoutEvent); expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); @@ -94,7 +94,7 @@ test('should fire only non-touch-related events on non-editable TextInput with n * user composite TextInput level, hence invoking the event handlers that * should be blocked by `editable={false}` prop. */ -test('should fire only non-touch-related events on non-editable wrapped TextInput', () => { +test('should fire only non-touch-related events on non-editable wrapped TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -112,10 +112,10 @@ test('should fire only non-touch-related events on non-editable wrapped TextInpu ); const subject = screen.getByTestId('subject'); - fireEvent(subject, 'focus'); - fireEvent.changeText(subject, 'Text'); - fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'focus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'layout', layoutEvent); expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); @@ -126,7 +126,7 @@ test('should fire only non-touch-related events on non-editable wrapped TextInpu /** * Ditto testing for even deeper hierarchy of TextInput wrappers. */ -test('should fire only non-touch-related events on non-editable double wrapped TextInput', () => { +test('should fire only non-touch-related events on non-editable double wrapped TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -144,10 +144,10 @@ test('should fire only non-touch-related events on non-editable double wrapped T ); const subject = screen.getByTestId('subject'); - fireEvent(subject, 'focus'); - fireEvent.changeText(subject, 'Text'); - fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'focus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'layout', layoutEvent); expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 7e3474bb0..ebf47122c 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; -import { fireEvent, render, screen } from '..'; +import { fireEvent, render, screen, waitFor } from '..'; type OnPressComponentProps = { onPress: () => void; @@ -50,25 +50,25 @@ const CustomEventComponentWithCustomName = ({ ); describe('fireEvent', () => { - test('should invoke specified event', () => { + test('should invoke specified event', async () => { const onPressMock = jest.fn(); render(); - fireEvent(screen.getByText('Press me'), 'press'); + await fireEvent(screen.getByText('Press me'), 'press'); expect(onPressMock).toHaveBeenCalled(); }); - test('should invoke specified event on parent element', () => { + test('should invoke specified event on parent element', async () => { const onPressMock = jest.fn(); const text = 'New press text'; render(); - fireEvent(screen.getByText(text), 'press'); + await fireEvent(screen.getByText(text), 'press'); expect(onPressMock).toHaveBeenCalled(); }); - test('should invoke event with custom name', () => { + test('should invoke event with custom name', async () => { const handlerMock = jest.fn(); const EVENT_DATA = 'event data'; @@ -78,13 +78,13 @@ describe('fireEvent', () => { , ); - fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + await fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); }); }); -test('fireEvent.press', () => { +test('fireEvent.press', async () => { const onPressMock = jest.fn(); const text = 'Fireevent press'; const eventData = { @@ -95,12 +95,12 @@ test('fireEvent.press', () => { }; render(); - fireEvent.press(screen.getByText(text), eventData); + await fireEvent.press(screen.getByText(text), eventData); expect(onPressMock).toHaveBeenCalledWith(eventData); }); -test('fireEvent.scroll', () => { +test('fireEvent.scroll', async () => { const onScrollMock = jest.fn(); const eventData = { nativeEvent: { @@ -116,12 +116,12 @@ test('fireEvent.scroll', () => { , ); - fireEvent.scroll(screen.getByText('XD'), eventData); + await fireEvent.scroll(screen.getByText('XD'), eventData); expect(onScrollMock).toHaveBeenCalledWith(eventData); }); -test('fireEvent.changeText', () => { +test('fireEvent.changeText', async () => { const onChangeTextMock = jest.fn(); render( @@ -131,41 +131,41 @@ test('fireEvent.changeText', () => { ); const input = screen.getByPlaceholderText('Customer placeholder'); - fireEvent.changeText(input, 'content'); + await fireEvent.changeText(input, 'content'); expect(onChangeTextMock).toHaveBeenCalledWith('content'); }); -it('sets native state value for unmanaged text inputs', () => { +it('sets native state value for unmanaged text inputs', async () => { render(); const input = screen.getByTestId('input'); expect(input).toHaveDisplayValue(''); - fireEvent.changeText(input, 'abc'); + await fireEvent.changeText(input, 'abc'); expect(input).toHaveDisplayValue('abc'); }); -test('custom component with custom event name', () => { +test('custom component with custom event name', async () => { const handlePress = jest.fn(); render(); - fireEvent(screen.getByText('Custom component'), 'handlePress'); + await fireEvent(screen.getByText('Custom component'), 'handlePress'); expect(handlePress).toHaveBeenCalled(); }); -test('event with multiple handler parameters', () => { +test('event with multiple handler parameters', async () => { const handlePress = jest.fn(); render(); - fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); + await fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); }); -test('should not fire on disabled TouchableOpacity', () => { +test('should not fire on disabled TouchableOpacity', async () => { const handlePress = jest.fn(); render( @@ -175,11 +175,11 @@ test('should not fire on disabled TouchableOpacity', () => { , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); -test('should not fire on disabled Pressable', () => { +test('should not fire on disabled Pressable', async () => { const handlePress = jest.fn(); render( @@ -189,11 +189,11 @@ test('should not fire on disabled Pressable', () => { , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); -test('should not fire inside View with pointerEvents="none" in props', () => { +test('should not fire inside View with pointerEvents="none"', async () => { const onPress = jest.fn(); render( @@ -203,42 +203,12 @@ test('should not fire inside View with pointerEvents="none" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should not fire inside View with pointerEvents="none" in styles', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="none" in styles array', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only" in props', () => { +test('should not fire inside View with pointerEvents="box-only"', async () => { const onPress = jest.fn(); render( @@ -248,27 +218,12 @@ test('should not fire inside View with pointerEvents="box-only" in props', () => , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only" in styles', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should fire inside View with pointerEvents="box-none" in props', () => { +test('should fire inside View with pointerEvents="box-none"', async () => { const onPress = jest.fn(); render( @@ -278,27 +233,12 @@ test('should fire inside View with pointerEvents="box-none" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).toHaveBeenCalledTimes(2); }); -test('should fire inside View with pointerEvents="box-none" in styles', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should fire inside View with pointerEvents="auto" in props', () => { +test('should fire inside View with pointerEvents="auto"', async () => { const onPress = jest.fn(); render( @@ -308,27 +248,12 @@ test('should fire inside View with pointerEvents="auto" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should fire inside View with pointerEvents="auto" in styles', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).toHaveBeenCalledTimes(2); }); -test('should not fire deeply inside View with pointerEvents="box-only" in props', () => { +test('should not fire deeply inside View with pointerEvents="box-only"', async () => { const onPress = jest.fn(); render( @@ -340,79 +265,38 @@ test('should not fire deeply inside View with pointerEvents="box-only" in props' , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire deeply inside View with pointerEvents="box-only" in styles', () => { - const onPress = jest.fn(); - render( - - - - Trigger - - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should fire non-pointer events inside View with pointerEvents="box-none" in props', () => { +test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { const onTouchStart = jest.fn(); render(); - fireEvent(screen.getByTestId('view'), 'touchStart'); + await fireEvent(screen.getByTestId('view'), 'touchStart'); expect(onTouchStart).toHaveBeenCalled(); }); -test('should fire non-pointer events inside View with pointerEvents="box-none" in styles', () => { - const onTouchStart = jest.fn(); - render(); - - fireEvent(screen.getByTestId('view'), 'touchStart'); - expect(onTouchStart).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none" in props', () => { +test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { const onLayout = jest.fn(); render(); - fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none" in styles', () => { - const onLayout = jest.fn(); - render(); - - fireEvent(screen.getByTestId('view'), 'layout'); + await fireEvent(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" in props', () => { +test('should fire on Pressable with pointerEvents="box-only', async () => { const onPress = jest.fn(); render(); - fireEvent.press(screen.getByTestId('pressable')); + await fireEvent.press(screen.getByTestId('pressable')); expect(onPress).toHaveBeenCalled(); }); -test('should fire on Pressable with pointerEvents="box-only" in styles', () => { - const onPress = jest.fn(); - render(); - - fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); -}); - -test('should pass event up on disabled TouchableOpacity', () => { +test('should pass event up on disabled TouchableOpacity', async () => { const handleInnerPress = jest.fn(); const handleOuterPress = jest.fn(); render( @@ -423,12 +307,12 @@ test('should pass event up on disabled TouchableOpacity', () => { , ); - fireEvent.press(screen.getByText('Inner Trigger')); + await fireEvent.press(screen.getByText('Inner Trigger')); expect(handleInnerPress).not.toHaveBeenCalled(); expect(handleOuterPress).toHaveBeenCalledTimes(1); }); -test('should pass event up on disabled Pressable', () => { +test('should pass event up on disabled Pressable', async () => { const handleInnerPress = jest.fn(); const handleOuterPress = jest.fn(); render( @@ -439,7 +323,7 @@ test('should pass event up on disabled Pressable', () => { , ); - fireEvent.press(screen.getByText('Inner Trigger')); + await fireEvent.press(screen.getByText('Inner Trigger')); expect(handleInnerPress).not.toHaveBeenCalled(); expect(handleOuterPress).toHaveBeenCalledTimes(1); }); @@ -456,11 +340,11 @@ const TestComponent = ({ onPress }: TestComponentProps) => { ); }; -test('is not fooled by non-native disabled prop', () => { +test('is not fooled by non-native disabled prop', async () => { const handlePress = jest.fn(); render(); - fireEvent.press(screen.getByText('Trigger Test')); + await fireEvent.press(screen.getByText('Trigger Test')); expect(handlePress).toHaveBeenCalledTimes(1); }); @@ -479,7 +363,7 @@ function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableCo ); } -test('is not fooled by non-responder wrapping host elements', () => { +test('is not fooled by non-responder wrapping host elements', async () => { const handlePress = jest.fn(); render( @@ -488,7 +372,7 @@ test('is not fooled by non-responder wrapping host elements', () => { , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); @@ -507,12 +391,12 @@ function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { ); } -test('has only onMove', () => { +test('has only onMove', async () => { const handleDrag = jest.fn(); render(); - fireEvent(screen.getByText('Trigger'), 'responderMove', { + await fireEvent(screen.getByText('Trigger'), 'responderMove', { touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, }); expect(handleDrag).toHaveBeenCalled(); @@ -521,40 +405,244 @@ test('has only onMove', () => { // Those events ideally should be triggered through `fireEvent.scroll`, but they are handled at the // native level, so we need to support manually triggering them describe('native events', () => { - test('triggers onScrollBeginDrag', () => { + test('triggers onScrollBeginDrag', async () => { const onScrollBeginDragSpy = jest.fn(); render(); - fireEvent(screen.getByTestId('test-id'), 'onScrollBeginDrag'); + await fireEvent(screen.getByTestId('test-id'), 'onScrollBeginDrag'); expect(onScrollBeginDragSpy).toHaveBeenCalled(); }); - test('triggers onScrollEndDrag', () => { + test('triggers onScrollEndDrag', async () => { const onScrollEndDragSpy = jest.fn(); render(); - fireEvent(screen.getByTestId('test-id'), 'onScrollEndDrag'); + await fireEvent(screen.getByTestId('test-id'), 'onScrollEndDrag'); expect(onScrollEndDragSpy).toHaveBeenCalled(); }); - test('triggers onMomentumScrollBegin', () => { + test('triggers onMomentumScrollBegin', async () => { const onMomentumScrollBeginSpy = jest.fn(); render(); - fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); + await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); }); - test('triggers onMomentumScrollEnd', () => { + test('triggers onMomentumScrollEnd', async () => { const onMomentumScrollEndSpy = jest.fn(); render(); - fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); + await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); expect(onMomentumScrollEndSpy).toHaveBeenCalled(); }); }); -test('should handle unmounted elements gracefully', () => { +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(); + }); + + // fireEvent should work on the resolved component + await fireEvent.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 fireEvent.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 fireEvent.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 fireEvent.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 fireEvent.press(screen.getByText('Load Data')); + expect(onPressMock).toHaveBeenCalled(); + + // Rerender - now DataComponent should suspend + screen.rerender( + + + Loading data...}> + + + , + ); + + // Should show loading fallback + expect(screen.getByText('Loading data...')).toBeTruthy(); + }); +}); + +test('should handle unmounted elements gracefully in async mode', async () => { const onPress = jest.fn(); render( @@ -565,7 +653,7 @@ test('should handle unmounted elements gracefully', () => { const element = screen.getByText('Test'); screen.unmount(); - // Firing event on unmounted element should not crash - fireEvent.press(element); + // Firing async event on unmounted element should not crash + await fireEvent.press(element); expect(onPress).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/react-native-gesture-handler.test.tsx b/src/__tests__/react-native-gesture-handler.test.tsx index 989ad03cf..eead8ce31 100644 --- a/src/__tests__/react-native-gesture-handler.test.tsx +++ b/src/__tests__/react-native-gesture-handler.test.tsx @@ -6,7 +6,7 @@ import { Pressable } from 'react-native-gesture-handler'; import { fireEvent, render, screen, userEvent } from '..'; import { createEventLogger, getEventsNames } from '../test-utils/events'; -test('fireEvent can invoke press events for RNGH Pressable', () => { +test('fireEvent can invoke press events for RNGH Pressable', async () => { const onPress = jest.fn(); const onPressIn = jest.fn(); const onPressOut = jest.fn(); @@ -26,16 +26,16 @@ test('fireEvent can invoke press events for RNGH Pressable', () => { const pressable = screen.getByTestId('pressable'); - fireEvent.press(pressable); + await fireEvent.press(pressable); expect(onPress).toHaveBeenCalled(); - fireEvent(pressable, 'pressIn'); + await fireEvent(pressable, 'pressIn'); expect(onPressIn).toHaveBeenCalled(); - fireEvent(pressable, 'pressOut'); + await fireEvent(pressable, 'pressOut'); expect(onPressOut).toHaveBeenCalled(); - fireEvent(pressable, 'longPress'); + await fireEvent(pressable, 'longPress'); expect(onLongPress).toHaveBeenCalled(); }); diff --git a/src/__tests__/render-debug.test.tsx b/src/__tests__/render-debug.test.tsx index 16418c19e..544c2e45a 100644 --- a/src/__tests__/render-debug.test.tsx +++ b/src/__tests__/render-debug.test.tsx @@ -103,9 +103,9 @@ test('debug', () => { expect(`${mockCalls[2][0]}\n${mockCalls[2][1]}`).toMatchSnapshot('All Props'); }); -test('debug changing component', () => { +test('debug changing component', async () => { render(); - fireEvent.press(screen.getByRole('button', { name: 'Change freshness!' })); + await fireEvent.press(screen.getByRole('button', { name: 'Change freshness!' })); screen.debug({ mapProps: null }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index ba4c21fe5..ae5c8d350 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -79,12 +79,12 @@ test('supports basic rendering', () => { expect(screen.root).toBeOnTheScreen(); }); -test('rerender', () => { +test('rerender', async () => { const fn = jest.fn(); render(); expect(fn).toHaveBeenCalledTimes(0); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); expect(fn).toHaveBeenCalledTimes(1); screen.rerender(); diff --git a/src/__tests__/wait-for-element-to-be-removed.test.tsx b/src/__tests__/wait-for-element-to-be-removed.test.tsx index 787b2bce0..0dda4ae87 100644 --- a/src/__tests__/wait-for-element-to-be-removed.test.tsx +++ b/src/__tests__/wait-for-element-to-be-removed.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { test('waits when using getBy query', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const element = screen.getByText('Observed Element'); expect(element).toBeTruthy(); @@ -44,7 +44,7 @@ test('waits when using getBy query', async () => { test('waits when using getAllBy query', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const elements = screen.getAllByText('Observed Element'); expect(elements).toBeTruthy(); @@ -56,7 +56,7 @@ test('waits when using getAllBy query', async () => { test('waits when using queryBy query', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const element = screen.getByText('Observed Element'); expect(element).toBeTruthy(); @@ -68,7 +68,7 @@ test('waits when using queryBy query', async () => { test('waits when using queryAllBy query', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const elements = screen.getAllByText('Observed Element'); expect(elements).toBeTruthy(); @@ -80,7 +80,7 @@ test('waits when using queryAllBy query', async () => { test('checks if elements exist at start', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); expect(screen.queryByText('Observed Element')).toBeNull(); await expect( @@ -93,7 +93,7 @@ test('checks if elements exist at start', async () => { test('waits until timeout', async () => { render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); expect(screen.getByText('Observed Element')).toBeTruthy(); await expect( diff --git a/src/fire-event.ts b/src/fire-event.ts index 71c32127b..fb33ce365 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -126,7 +126,7 @@ type EventName = StringWithAutocomplete< | EventNameExtractor >; -function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { +function deprecated_fireEventSync(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -146,16 +146,16 @@ function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[ return returnValue; } -fireEvent.press = (element: HostElement, ...data: unknown[]) => - fireEvent(element, 'press', ...data); +deprecated_fireEventSync.press = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'press', ...data); -fireEvent.changeText = (element: HostElement, ...data: unknown[]) => - fireEvent(element, 'changeText', ...data); +deprecated_fireEventSync.changeText = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'changeText', ...data); -fireEvent.scroll = (element: HostElement, ...data: unknown[]) => - fireEvent(element, 'scroll', ...data); +deprecated_fireEventSync.scroll = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'scroll', ...data); -async function fireEventAsync(element: HostElement, eventName: EventName, ...data: unknown[]) { +async function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -176,16 +176,16 @@ async function fireEventAsync(element: HostElement, eventName: EventName, ...dat return returnValue; } -fireEventAsync.press = async (element: HostElement, ...data: unknown[]) => - await fireEventAsync(element, 'press', ...data); +fireEvent.press = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'press', ...data); -fireEventAsync.changeText = async (element: HostElement, ...data: unknown[]) => - await fireEventAsync(element, 'changeText', ...data); +fireEvent.changeText = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'changeText', ...data); -fireEventAsync.scroll = async (element: HostElement, ...data: unknown[]) => - await fireEventAsync(element, 'scroll', ...data); +fireEvent.scroll = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'scroll', ...data); -export { fireEventAsync }; +export { fireEvent, deprecated_fireEventSync }; export default fireEvent; const scrollEventNames = new Set([ @@ -226,4 +226,4 @@ function tryGetContentOffset(event: unknown): Point | null { } return null; -} +} \ No newline at end of file diff --git a/src/pure.ts b/src/pure.ts index 5503cf968..7b8c1b740 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { default as act } from './act'; export { default as cleanup, cleanupAsync } from './cleanup'; -export { default as fireEvent, fireEventAsync } from './fire-event'; +export { default as fireEvent, deprecated_fireEventSync } from './fire-event'; export { default as render } from './render'; export { default as renderAsync } from './render-async'; export { default as waitFor } from './wait-for'; diff --git a/src/queries/__tests__/display-value.test.tsx b/src/queries/__tests__/display-value.test.tsx index 3da690acd..97d51f0cf 100644 --- a/src/queries/__tests__/display-value.test.tsx +++ b/src/queries/__tests__/display-value.test.tsx @@ -203,12 +203,12 @@ test('error message renders the element tree, preserving only helpful props', as `); }); -test('supports unmanaged TextInput element', () => { +test('supports unmanaged TextInput element', async () => { render(); const input = screen.getByDisplayValue(''); expect(input).toHaveDisplayValue(''); - fireEvent.changeText(input, 'Hello!'); + await fireEvent.changeText(input, 'Hello!'); expect(input).toHaveDisplayValue('Hello!'); }); diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index 29988636b..360c1ff11 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -130,7 +130,7 @@ describe('scrollTo()', () => { const { events } = renderScrollViewWithToolkit(); const user = userEvent.setup(); - fireEvent.scroll(screen.getByTestId('scrollView'), { + await fireEvent.scroll(screen.getByTestId('scrollView'), { nativeEvent: { contentOffset: { y: 100 } }, }); await user.scrollTo(screen.getByTestId('scrollView'), { y: 200 }); diff --git a/website/docs/14.x/docs/advanced/understanding-act.mdx b/website/docs/14.x/docs/advanced/understanding-act.mdx index a6dfd519d..c1b6e2b58 100644 --- a/website/docs/14.x/docs/advanced/understanding-act.mdx +++ b/website/docs/14.x/docs/advanced/understanding-act.mdx @@ -87,7 +87,7 @@ Therefore, we should use `act` whenever there is some action that causes element - re-rendering of component -`renderer.update` call - triggering any event handlers that cause component tree render -Thankfully, for these basic cases RNTL has got you covered as our `render`, `update` and `fireEvent` methods already wrap their calls in sync `act` so that you do not have to do it explicitly. +Thankfully, for these basic cases RNTL has got you covered as our `render`, `update` and `fireEvent` methods already wrap their calls in `act` so that you do not have to do it explicitly. Note that `act` calls can be safely nested and internally form a stack of calls. However, overlapping `act` calls, which can be achieved using async version of `act`, [are not supported](https://github.com/facebook/react/blob/main/packages/react/src/ReactAct.js#L161). diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index b91b73533..f4a3d0200 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -10,17 +10,19 @@ Use Fire Event for cases not supported by User Event and for triggering event ha ::: ```ts -function fireEvent(element: ReactTestInstance, eventName: string, ...data: unknown[]): void; +function fireEvent(element: ReactTestInstance, eventName: string, ...data: unknown[]): Promise; ``` The `fireEvent` API allows you to trigger all kinds of event handlers on both host and composite components. It will try to invoke a single event handler traversing the component tree bottom-up from passed element and trying to find enabled event handler named `onXxx` when `xxx` is the name of the event passed. Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. +This function uses async `act` function internally to ensure all pending React updates are executed during event handling. + ```jsx import { render, screen, fireEvent } from '@testing-library/react-native'; -test('fire changeText event', () => { +test('fire changeText event', async () => { const onEventMock = jest.fn(); render( // MyComponent renders TextInput which has a placeholder 'Enter details' @@ -28,7 +30,7 @@ test('fire changeText event', () => { ); - fireEvent(screen.getByPlaceholderText('change'), 'onChangeText', 'ab'); + await fireEvent(screen.getByPlaceholderText('change'), 'onChangeText', 'ab'); expect(onEventMock).toHaveBeenCalledWith('ab'); }); ``` @@ -52,7 +54,7 @@ render( ); // you can omit the `on` prefix -fireEvent(screen.getByPlaceholderText('my placeholder'), 'blur'); +await fireEvent(screen.getByPlaceholderText('my placeholder'), 'blur'); ``` FireEvent exposes convenience methods for common events like: `press`, `changeText`, `scroll`. @@ -67,7 +69,7 @@ It is recommended to use the User Event [`press()`](docs/api/events/user-event#p fireEvent.press: ( element: ReactTestInstance, ...data: Array, -) => void +) => Promise ``` Invokes `press` event handler on the element or parent element in the tree. @@ -92,7 +94,7 @@ render( ); -fireEvent.press(screen.getByText('Press me'), eventData); +await fireEvent.press(screen.getByText('Press me'), eventData); expect(onPressMock).toHaveBeenCalledWith(eventData); ``` @@ -106,7 +108,7 @@ It is recommended to use the User Event [`type()`](docs/api/events/user-event#ty fireEvent.changeText: ( element: ReactTestInstance, ...data: Array, -) => void +) => Promise ``` Invokes `changeText` event handler on the element or parent element in the tree. @@ -124,7 +126,7 @@ render( ); -fireEvent.changeText(screen.getByPlaceholderText('Enter data'), CHANGE_TEXT); +await fireEvent.changeText(screen.getByPlaceholderText('Enter data'), CHANGE_TEXT); ``` ### `fireEvent.scroll` {#scroll} @@ -137,7 +139,7 @@ Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEv fireEvent.scroll: ( element: ReactTestInstance, ...data: Array, -) => void +) => Promise ``` Invokes `scroll` event handler on the element or parent element in the tree. @@ -163,85 +165,14 @@ render( ); -fireEvent.scroll(screen.getByText('scroll-view'), eventData); +await fireEvent.scroll(screen.getByText('scroll-view'), eventData); ``` -:::note -Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEvent.scroll` for `ScrollView`, `FlatList`, and `SectionList` components. User Event provides a more realistic event simulation based on React Native runtime behavior. -::: - -## `fireEventAsync` {#fire-event-async} - -:::info RNTL minimal version - -This API requires RNTL v13.3.0 or later. - -::: +## `deprecated_fireEventSync` {#deprecated-fire-event-sync} ```ts -async function fireEventAsync( - element: ReactTestInstance, - eventName: string, - ...data: unknown[] -): Promise; -``` - -The `fireEventAsync` function is the async version of [`fireEvent`](#fire-event) designed for working with React 19 and React Suspense. This function uses async `act` function internally to ensure all pending React updates are executed during event handling. - -```jsx -import { renderAsync, screen, fireEventAsync } from '@testing-library/react-native'; - -test('fire event test', async () => { - await renderAsync(); - - await fireEventAsync(screen.getByText('Button'), 'press'); - expect(screen.getByText('Action completed')).toBeOnTheScreen(); -}); -``` - -Like `fireEvent`, `fireEventAsync` also provides convenience methods for common events: `fireEventAsync.press`, `fireEventAsync.changeText`, and `fireEventAsync.scroll`. - -### `fireEventAsync.press` {#async-press} - -:::note -It is recommended to use the User Event [`press()`](docs/api/events/user-event#press) helper instead as it offers more realistic simulation of press interaction, including pressable support. -::: - -```tsx -fireEventAsync.press: ( - element: ReactTestInstance, - ...data: Array, -) => Promise -``` - -Async version of [`fireEvent.press`](#press) designed for React 19 and React Suspense. Use when `press` event handlers trigger suspense boundaries. - -### `fireEventAsync.changeText` {#async-change-text} - -:::note -It is recommended to use the User Event [`type()`](docs/api/events/user-event#type) helper instead as it offers more realistic simulation of text change interaction, including key-by-key typing, element focus, and other editing events. -::: - -```tsx -fireEventAsync.changeText: ( - element: ReactTestInstance, - ...data: Array, -) => Promise -``` - -Async version of [`fireEvent.changeText`](#change-text) designed for React 19 and React Suspense. Use when `changeText` event handlers trigger suspense boundaries. - -### `fireEventAsync.scroll` {#async-scroll} - -:::note -Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEventAsync.scroll` for `ScrollView`, `FlatList`, and `SectionList` components. User Event provides a more realistic event simulation based on React Native runtime behavior. -::: - -```tsx -fireEventAsync.scroll: ( - element: ReactTestInstance, - ...data: Array, -) => Promise +function deprecated_fireEventSync(element: ReactTestInstance, eventName: string, ...data: unknown[]): void; ``` -Async version of [`fireEvent.scroll`](#scroll) designed for React 19 and React Suspense. Use when `scroll` event handlers trigger suspense boundaries. +Synchronous version of `fireEvent`. Deprecated and will be removed in future versions. +Use only if you cannot use `await fireEvent` for some reason. diff --git a/website/docs/14.x/docs/guides/react-19.mdx b/website/docs/14.x/docs/guides/react-19.mdx index 17ba65b97..cfb61cbbf 100644 --- a/website/docs/14.x/docs/guides/react-19.mdx +++ b/website/docs/14.x/docs/guides/react-19.mdx @@ -16,7 +16,7 @@ RNTL 13.3 introduces async versions of the core testing APIs to handle React 19' **Event APIs:** -- **[`fireEventAsync`](docs/api/events/fire-event#fire-event-async)** - async version of `fireEvent` +- **[`fireEvent`](docs/api/events/fire-event)** - updated to be async by default. ## APIs that remain unchanged diff --git a/website/docs/14.x/docs/migration/v14.mdx b/website/docs/14.x/docs/migration/v14.mdx index f4476c1bd..49d974199 100644 --- a/website/docs/14.x/docs/migration/v14.mdx +++ b/website/docs/14.x/docs/migration/v14.mdx @@ -4,6 +4,63 @@ This guide describes the migration to React Native Testing Library version 14 fr ## Breaking changes +### `fireEvent` is now async by default + +In v14, `fireEvent` and its helpers (`press`, `changeText`, `scroll`) are now async by default and return a Promise. This change makes it compatible with React 19, React Suspense, and `React.use()`. + +**Before (v13):** + +```ts +import { fireEvent, screen } from '@testing-library/react-native'; + +it('should press button', () => { + render(); + fireEvent.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); +}); +``` + +**After (v14):** + +```ts +import { fireEvent, screen } from '@testing-library/react-native'; + +it('should press button', async () => { + render(); + await fireEvent.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); +}); +``` + +### Migration path + +To ease migration, we provide `deprecated_fireEventSync` which maintains the same synchronous behavior as v13. This allows you to migrate gradually. + +#### `deprecated_fireEventSync` {#deprecated-fire-event-sync} + +```ts +function deprecated_fireEventSync( + element: ReactTestInstance, + eventName: string, + ...data: unknown[] +): void; +``` + +**⚠️ Deprecated**: This function is provided for migration purposes only. Use async `fireEvent` instead. + +The synchronous version of `fireEvent` that returns immediately without awaiting React updates. This maintains backward compatibility with v13 tests but is not recommended for new code. + +```ts +// Old v13 code (still works but deprecated) +import { deprecated_fireEventSync } from '@testing-library/react-native'; + +it('should press button', () => { + render(); + deprecated_fireEventSync.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); +}); +``` + ### `renderHook` is now async by default In v14, `renderHook` is now async by default and returns a Promise. This change makes it compatible with React 19, React Suspense, and `React.use()`. From c6dfc9f3a9d928d1b04a93f7dd807ec57c9900b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 4 Jan 2026 18:28:18 +0100 Subject: [PATCH 3/5] refactor: make `fireEvent` async by default --- .gemini/settings.json | 4 +- AGENTS.md | 64 +++++++++---------- src/__tests__/wait-for.test.tsx | 14 ++-- src/fire-event.ts | 2 +- typings/index.flow.js | 15 ++++- .../docs/14.x/docs/api/events/fire-event.mdx | 12 +++- 6 files changed, 66 insertions(+), 45 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 523d3da35..2facafdd1 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,5 +1,5 @@ { "context": { - "contextFileName": "AGENTS.md" + "contextFileName": "AGENTS.md" } -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md index 4a1d5c437..ec07f6d8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,43 +6,43 @@ This document provides context for the Gemini code assistant to understand the ` `@testing-library/react-native` (RNTL) provides a set of utilities for testing React Native components. It is designed to facilitate writing tests that resemble how users interact with the application, avoiding implementation details. -* **Core Principle:** "The more your tests resemble the way your software is used, the more confidence they can give you." -* **Tech Stack:** TypeScript, React Native, Jest. -* **Architecture:** The library simulates the React Native runtime on top of `universal-test-renderer`. +- **Core Principle:** "The more your tests resemble the way your software is used, the more confidence they can give you." +- **Tech Stack:** TypeScript, React Native, Jest. +- **Architecture:** The library simulates the React Native runtime on top of `universal-test-renderer`. ## Building and Running The project uses `yarn` for dependency management and script execution. -* **Installation:** `yarn install` -* **Run Tests:** `yarn test` (Runs Jest) -* **Run Tests (CI):** `yarn test:ci` (Runs Jest with worker limits) -* **Lint Code:** `yarn lint` (Runs ESLint on `src`) -* **Type Check:** `yarn typecheck` (Runs TypeScript compiler) -* **Format Check:** `yarn prettier` -* **Validate All:** `yarn validate` (Runs Prettier, ESLint, Typecheck, and Tests in sequence) -* **Build Project:** `yarn build` (Cleans, builds JS with Babel, builds TS types, and copies Flow types) +- **Installation:** `yarn install` +- **Run Tests:** `yarn test` (Runs Jest) +- **Run Tests (CI):** `yarn test:ci` (Runs Jest with worker limits) +- **Lint Code:** `yarn lint` (Runs ESLint on `src`) +- **Type Check:** `yarn typecheck` (Runs TypeScript compiler) +- **Format Check:** `yarn prettier` +- **Validate All:** `yarn validate` (Runs Prettier, ESLint, Typecheck, and Tests in sequence) +- **Build Project:** `yarn build` (Cleans, builds JS with Babel, builds TS types, and copies Flow types) ## Development Conventions -* **Code Style:** - * **Linting:** ESLint is configured with `@callstack/eslint-config` and `typescript-eslint`. It enforces strict rules, including `no-console` and consistent type imports. - * **Formatting:** Prettier is used for code formatting (single quotes, trailing commas). - * **Imports:** Sorted using `eslint-plugin-simple-import-sort`. - -* **Testing:** - * **Framework:** Jest with `react-native` preset. - * **Location:** Tests are located within `src`, typically in `__tests__` directories or co-located. - * **Setup:** `jest-setup.ts` configures the test environment. `src/index.ts` automatically configures cleanup after each test unless skipped. - * **Coverage:** Collected from `src`, excluding tests. - -* **Commits & Releases:** - * **Commits:** Follow the **Conventional Commits** specification (e.g., `fix:`, `feat:`, `chore:`). This is enforced and used for changelog generation. - * **Releases:** Managed via `release-it`. - -* **File Structure:** - * `src/`: Source code. - * `src/pure.ts`: Core logic without side effects (no auto-cleanup). - * `src/index.ts`: Main entry point, re-exports `pure` and adds side effects (auto-cleanup). - * `examples/`: Example React Native applications using the library. - * `website/`: Documentation website. +- **Code Style:** + - **Linting:** ESLint is configured with `@callstack/eslint-config` and `typescript-eslint`. It enforces strict rules, including `no-console` and consistent type imports. + - **Formatting:** Prettier is used for code formatting (single quotes, trailing commas). + - **Imports:** Sorted using `eslint-plugin-simple-import-sort`. + +- **Testing:** + - **Framework:** Jest with `react-native` preset. + - **Location:** Tests are located within `src`, typically in `__tests__` directories or co-located. + - **Setup:** `jest-setup.ts` configures the test environment. `src/index.ts` automatically configures cleanup after each test unless skipped. + - **Coverage:** Collected from `src`, excluding tests. + +- **Commits & Releases:** + - **Commits:** Follow the **Conventional Commits** specification (e.g., `fix:`, `feat:`, `chore:`). This is enforced and used for changelog generation. + - **Releases:** Managed via `release-it`. + +- **File Structure:** + - `src/`: Source code. + - `src/pure.ts`: Core logic without side effects (no auto-cleanup). + - `src/index.ts`: Main entry point, re-exports `pure` and adds side effects (auto-cleanup). + - `examples/`: Example React Native applications using the library. + - `website/`: Documentation website. diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 7568d2760..4efd0253c 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -40,7 +40,7 @@ afterEach(() => { test('waits for element until it stops throwing', async () => { render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); expect(screen.queryByText('Fresh')).toBeNull(); @@ -52,7 +52,7 @@ test('waits for element until it stops throwing', async () => { test('waits for element until timeout is met', async () => { render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); @@ -65,7 +65,7 @@ test('waitFor defaults to asyncWaitTimeout config option', async () => { configure({ asyncUtilTimeout: 100 }); render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait @@ -77,7 +77,7 @@ test('waitFor timeout option takes precendence over `asyncWaitTimeout` config op configure({ asyncUtilTimeout: 2000 }); render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait @@ -127,7 +127,7 @@ test('waits for async event with fireEvent', async () => { const spy = jest.fn(); render(); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); await waitFor(() => { expect(spy).toHaveBeenCalled(); @@ -140,7 +140,7 @@ test.each([false, true])( jest.useFakeTimers({ legacyFakeTimers }); render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); expect(screen.queryByText('Fresh')).toBeNull(); jest.advanceTimersByTime(300); @@ -305,7 +305,7 @@ test.each([ await waitFor(() => screen.getByText('red')); // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(onPress).toHaveBeenCalledWith('red'); }, ); diff --git a/src/fire-event.ts b/src/fire-event.ts index fb33ce365..849788bb0 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -226,4 +226,4 @@ function tryGetContentOffset(event: unknown): Point | null { } return null; -} \ No newline at end of file +} diff --git a/typings/index.flow.js b/typings/index.flow.js index ea383d071..e89c469f2 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -286,9 +286,21 @@ type FireEventFunction = ( element: ReactTestInstance, eventName: string, ...data: Array -) => any; +) => Promise; type FireEventAPI = FireEventFunction & { + press: (element: ReactTestInstance, ...data: Array) => Promise, + changeText: (element: ReactTestInstance, ...data: Array) => Promise, + scroll: (element: ReactTestInstance, ...data: Array) => Promise, +}; + +type DeprecatedFireEventSyncFunction = ( + element: ReactTestInstance, + eventName: string, + ...data: Array +) => any; + +type DeprecatedFireEventSyncAPI = DeprecatedFireEventSyncFunction & { press: (element: ReactTestInstance, ...data: Array) => any, changeText: (element: ReactTestInstance, ...data: Array) => any, scroll: (element: ReactTestInstance, ...data: Array) => any, @@ -325,6 +337,7 @@ declare module '@testing-library/react-native' { declare export var cleanup: () => void; declare export var fireEvent: FireEventAPI; + declare export var deprecated_fireEventSync: DeprecatedFireEventSyncAPI; declare export var waitFor: WaitForFunction; diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index f4a3d0200..fef427999 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -10,7 +10,11 @@ Use Fire Event for cases not supported by User Event and for triggering event ha ::: ```ts -function fireEvent(element: ReactTestInstance, eventName: string, ...data: unknown[]): Promise; +function fireEvent( + element: ReactTestInstance, + eventName: string, + ...data: unknown[] +): Promise; ``` The `fireEvent` API allows you to trigger all kinds of event handlers on both host and composite components. It will try to invoke a single event handler traversing the component tree bottom-up from passed element and trying to find enabled event handler named `onXxx` when `xxx` is the name of the event passed. @@ -171,7 +175,11 @@ await fireEvent.scroll(screen.getByText('scroll-view'), eventData); ## `deprecated_fireEventSync` {#deprecated-fire-event-sync} ```ts -function deprecated_fireEventSync(element: ReactTestInstance, eventName: string, ...data: unknown[]): void; +function deprecated_fireEventSync( + element: ReactTestInstance, + eventName: string, + ...data: unknown[] +): void; ``` Synchronous version of `fireEvent`. Deprecated and will be removed in future versions. From 9f734a179e93a451bef5143ac65b9bc2262c307c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 4 Jan 2026 18:30:09 +0100 Subject: [PATCH 4/5] . --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec07f6d8b..34c265f26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# Gemini Code Assistant Context +# Code Assistant Context -This document provides context for the Gemini code assistant to understand the `@testing-library/react-native` project. +This document provides context for the any code assistant to understand the `@testing-library/react-native` project. ## Project Overview @@ -32,7 +32,7 @@ The project uses `yarn` for dependency management and script execution. - **Testing:** - **Framework:** Jest with `react-native` preset. - - **Location:** Tests are located within `src`, typically in `__tests__` directories or co-located. + - **Location:** Tests are located within `src`, typically co-located in `__tests__` directories. - **Setup:** `jest-setup.ts` configures the test environment. `src/index.ts` automatically configures cleanup after each test unless skipped. - **Coverage:** Collected from `src`, excluding tests. From 0518ab2dbec271d1ab2c34632f98a646e71078e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Sun, 4 Jan 2026 18:36:18 +0100 Subject: [PATCH 5/5] . --- .../fire-event-sync.test.tsx} | 2 +- .../render-hook-sync.test.tsx | 2 +- src/fire-event.ts | 39 ++++++++++--------- src/pure.ts | 2 +- 4 files changed, 24 insertions(+), 21 deletions(-) rename src/__tests__/{deprecated-fire-event-sync.test.tsx => deprecated/fire-event-sync.test.tsx} (99%) rename src/__tests__/{ => deprecated}/render-hook-sync.test.tsx (98%) diff --git a/src/__tests__/deprecated-fire-event-sync.test.tsx b/src/__tests__/deprecated/fire-event-sync.test.tsx similarity index 99% rename from src/__tests__/deprecated-fire-event-sync.test.tsx rename to src/__tests__/deprecated/fire-event-sync.test.tsx index 2793bb6f7..d87f09c7f 100644 --- a/src/__tests__/deprecated-fire-event-sync.test.tsx +++ b/src/__tests__/deprecated/fire-event-sync.test.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; -import { deprecated_fireEventSync, render, screen } from '..'; +import { deprecated_fireEventSync, render, screen } from '../..'; type OnPressComponentProps = { onPress: () => void; diff --git a/src/__tests__/render-hook-sync.test.tsx b/src/__tests__/deprecated/render-hook-sync.test.tsx similarity index 98% rename from src/__tests__/render-hook-sync.test.tsx rename to src/__tests__/deprecated/render-hook-sync.test.tsx index 6b269b74a..53a5e2ca8 100644 --- a/src/__tests__/render-hook-sync.test.tsx +++ b/src/__tests__/deprecated/render-hook-sync.test.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import * as React from 'react'; -import { deprecated_renderHookSync } from '../pure'; +import { deprecated_renderHookSync } from '../../pure'; test('renders hook and returns committed result', () => { const { result } = deprecated_renderHookSync(() => { diff --git a/src/fire-event.ts b/src/fire-event.ts index 849788bb0..17e1fb119 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -126,7 +126,7 @@ type EventName = StringWithAutocomplete< | EventNameExtractor >; -function deprecated_fireEventSync(element: HostElement, eventName: EventName, ...data: unknown[]) { +async function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -139,23 +139,25 @@ function deprecated_fireEventSync(element: HostElement, eventName: EventName, .. } let returnValue; - void act(() => { + // eslint-disable-next-line require-await + await act(async () => { returnValue = handler(...data); }); return returnValue; } -deprecated_fireEventSync.press = (element: HostElement, ...data: unknown[]) => - deprecated_fireEventSync(element, 'press', ...data); +fireEvent.press = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'press', ...data); -deprecated_fireEventSync.changeText = (element: HostElement, ...data: unknown[]) => - deprecated_fireEventSync(element, 'changeText', ...data); +fireEvent.changeText = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'changeText', ...data); -deprecated_fireEventSync.scroll = (element: HostElement, ...data: unknown[]) => - deprecated_fireEventSync(element, 'scroll', ...data); +fireEvent.scroll = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'scroll', ...data); -async function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { +/** @deprecated - Use async `fireEvent` instead. */ +function deprecated_fireEventSync(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -168,25 +170,26 @@ async function fireEvent(element: HostElement, eventName: EventName, ...data: un } let returnValue; - // eslint-disable-next-line require-await - await act(async () => { + void act(() => { returnValue = handler(...data); }); return returnValue; } -fireEvent.press = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'press', ...data); +/** @deprecated - Use async `fireEvent.press` instead. */ +deprecated_fireEventSync.press = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'press', ...data); -fireEvent.changeText = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'changeText', ...data); +/** @deprecated - Use async `fireEvent.changeText` instead. */ +deprecated_fireEventSync.changeText = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'changeText', ...data); -fireEvent.scroll = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'scroll', ...data); +/** @deprecated - Use async `fireEvent.scroll` instead. */ +deprecated_fireEventSync.scroll = (element: HostElement, ...data: unknown[]) => + deprecated_fireEventSync(element, 'scroll', ...data); export { fireEvent, deprecated_fireEventSync }; -export default fireEvent; const scrollEventNames = new Set([ 'scroll', diff --git a/src/pure.ts b/src/pure.ts index 7b8c1b740..f36b153e6 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { default as act } from './act'; export { default as cleanup, cleanupAsync } from './cleanup'; -export { default as fireEvent, deprecated_fireEventSync } from './fire-event'; +export { fireEvent, deprecated_fireEventSync } from './fire-event'; export { default as render } from './render'; export { default as renderAsync } from './render-async'; export { default as waitFor } from './wait-for';