diff --git a/.github/actions/setup-deps-react-18/action.yml b/.github/actions/setup-deps-react-18/action.yml index c2159a6ea..27cb83956 100644 --- a/.github/actions/setup-deps-react-18/action.yml +++ b/.github/actions/setup-deps-react-18/action.yml @@ -9,9 +9,12 @@ runs: with: node-version-file: .nvmrc - - name: Cache deps + # Restore and save are split so the cache is saved right after a clean + # install. Later steps switch dependency versions in place, and a post-job + # save would leak those changes into the cache shared by every job. + - name: Restore deps cache id: yarn-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ./node_modules @@ -26,6 +29,15 @@ runs: run: yarn install --immutable shell: bash + - name: Save deps cache + if: steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ steps.yarn-cache.outputs.cache-primary-key }} + - name: Switch to React 18 run: | yarn add -D react@18.3.1 react-test-renderer@18.3.1 @types/react@18.3.1 @types/react-test-renderer@18.3.1 react-native@0.77.0 @react-native/babel-preset@0.77.0 diff --git a/.github/actions/setup-deps-rn-latest/action.yml b/.github/actions/setup-deps-rn-latest/action.yml index fb9a6ed98..e633be17b 100644 --- a/.github/actions/setup-deps-rn-latest/action.yml +++ b/.github/actions/setup-deps-rn-latest/action.yml @@ -14,9 +14,12 @@ runs: with: node-version-file: .nvmrc - - name: Cache deps + # Restore and save are split so the cache is saved right after a clean + # install. Later steps switch dependency versions in place, and a post-job + # save would leak those changes into the cache shared by every job. + - name: Restore deps cache id: yarn-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ./node_modules @@ -31,6 +34,15 @@ runs: run: yarn install --immutable shell: bash + - name: Save deps cache + if: steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ steps.yarn-cache.outputs.cache-primary-key }} + - name: Switch to React Native Latest run: | yarn add -D react-native@latest \ diff --git a/.github/actions/setup-deps-rn-next/action.yml b/.github/actions/setup-deps-rn-next/action.yml index 13700d1db..71d4fa009 100644 --- a/.github/actions/setup-deps-rn-next/action.yml +++ b/.github/actions/setup-deps-rn-next/action.yml @@ -14,9 +14,12 @@ runs: with: node-version-file: .nvmrc - - name: Cache deps + # Restore and save are split so the cache is saved right after a clean + # install. Later steps switch dependency versions in place, and a post-job + # save would leak those changes into the cache shared by every job. + - name: Restore deps cache id: yarn-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ./node_modules @@ -31,6 +34,15 @@ runs: run: yarn install --immutable shell: bash + - name: Save deps cache + if: steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ steps.yarn-cache.outputs.cache-primary-key }} + - name: Switch to React Native Next run: | yarn add -D react-native@next \ diff --git a/.github/actions/setup-deps-rn-nightly/action.yml b/.github/actions/setup-deps-rn-nightly/action.yml index 85fb654f9..2fc006c9c 100644 --- a/.github/actions/setup-deps-rn-nightly/action.yml +++ b/.github/actions/setup-deps-rn-nightly/action.yml @@ -14,9 +14,12 @@ runs: with: node-version-file: .nvmrc - - name: Cache deps + # Restore and save are split so the cache is saved right after a clean + # install. Later steps switch dependency versions in place, and a post-job + # save would leak those changes into the cache shared by every job. + - name: Restore deps cache id: yarn-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ./node_modules @@ -31,6 +34,15 @@ runs: run: yarn install --immutable shell: bash + - name: Save deps cache + if: steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ steps.yarn-cache.outputs.cache-primary-key }} + - name: Switch to React Native Nightly run: | yarn add -D \ diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index 82943625d..bf8af2d64 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -20,9 +20,12 @@ runs: with: node-version-file: .nvmrc - - name: Cache deps + # Restore and save are split so the cache is saved right after a clean + # install. Later steps switch dependency versions in place, and a post-job + # save would leak those changes into the cache shared by every job. + - name: Restore deps cache id: yarn-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ./node_modules @@ -37,6 +40,15 @@ runs: run: yarn install --immutable shell: bash + - name: Save deps cache + if: steps.yarn-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./node_modules + .yarn/install-state.gz + key: ${{ steps.yarn-cache.outputs.cache-primary-key }} + - name: Switch to React and React Native versions if: inputs.react-version != '' && inputs.react-native-version != '' run: | diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 17a06fba7..e3dadd4df 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -12,12 +12,16 @@ The files in `src/events/legacy/`: | `propagation.ts` | Bubbling vs direct events, walking up host and composite elements | | `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders | | `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` | -| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent`, and helpers shared with `userEvent` | +| `warnings.ts` | `eventDiagnostics` warnings for `fireEvent` | | `builders/` | Legacy event objects: `wrapNativeEvent()` (stubs from `baseSyntheticEvent()`), touch and responder events | -Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props), `native-state.ts` and `update-native-state.ts` ([native state](native-state.md) and how `fireEvent` updates it), `payloads.ts` (`nativeEvent` payloads matching what React Native sends on a device), `merge.ts` (deep merging custom props into them), and `types.ts`. +Code used by both event systems lives in `src/events/shared/`: `handler.ts` (finding the `on*` handler for an event name in props), `native-state.ts` and `update-native-state.ts` ([native state](native-state.md) and how `fireEvent` updates it), `payloads.ts` (`nativeEvent` payloads matching what React Native sends on a device), `merge.ts` (deep merging custom props into them), `pointer-events.ts` (whether `pointerEvents` lets an element be the target of touches), `warnings.ts` (formatting and logging `eventDiagnostics` warnings, used by both `fireEvent`s and `userEvent`), and `types.ts`. -`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`, and imports the rest only through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts` and `src/events/shared/native-state.ts`. +In the modern event system, `src/events/modern/pressability.ts` calls the `onPress`, `onPressIn`, `onPressOut` or `onLongPress` callback of the element that becomes the touch responder, reading `Pressable` and `Touchable*` callbacks from Pressability's `testOnly_pressabilityConfig()`. These callbacks aren't native events, so modern `fireEvent` sends the event types in `PRESSABILITY_EVENT_TYPES` (`src/events/modern/event-types.ts`) there instead of to `dispatchEvent()`: `fireEvent.press()`, and `fireEvent()` with `press`, `pressIn`, `pressOut` or `longPress`. `dispatchEvent()` still dispatches the native `press` event, which reaches only host `onPress` props, as `userEvent` needs. + +Modern `fireEvent`'s `eventDiagnostics` warnings are in `src/events/modern/warnings.ts`. `dispatchEvent()` and `dispatchPressabilityEvent()` return whether they called a handler, and `fireEvent` warns when neither a handler ran nor native state changed. `dispatchEvent()` skips no handlers, so its warning only says that no element on the path has one (only the target, for direct events). For Pressability events, `getPressabilityCallbackOwners()` lists the elements with the callback, and the warning names those blocked by `pointerEvents` or disabled, as legacy does. + +`src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`, and imports the rest through `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts` and `src/events/shared/native-state.ts`, except the warning helpers from `src/events/shared/warnings.ts`. ## `fireEvent` diff --git a/contributing/testing.md b/contributing/testing.md index ac38c4e2d..c6fe91cc8 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -23,6 +23,6 @@ Every change in `src/` should come with tests. Tests use Jest and live next to t Both projects share the same snapshots. `createEventLogger()` entries print only the `nativeEvent` of event payloads (`src/test-utils/event-serializer.ts`), so a snapshot is the same for a legacy event object and a modern `SyntheticEvent`, and a difference between the two systems fails the snapshot. Use `--selectProjects legacy` or `--selectProjects modern` to run one of them. -When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it. +When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/legacy/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way. -Tests of legacy behavior the modern event system doesn't have (several handler arguments, bubbling to composite props, direct events bubbling with a warning, ...) call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They run in the legacy event system in both projects, e.g. `src/events/legacy/__tests__/fire-event.test.tsx`. +Tests that can't run in the modern event system at all can call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They then run in the legacy event system in both projects. diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 07c89479b..203ce9998 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,93 +1,2997 @@ import * as React from 'react'; -import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import type { TextInputProps } from 'react-native'; +import { + FlatList, + Image, + ImageBackground, + Modal, + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import type { TestInstance } from 'test-renderer'; -import { configure, fireEvent, render, screen } from '../..'; -import { getConfig } from '../../config'; +import { fireEvent, render, screen } from '../..'; +import { configure, getConfig } from '../../config'; +import { _console, logger } from '../../helpers/logger'; import { SyntheticEvent } from '../modern/event'; +import { getEventHandlerName } from '../shared/handler'; +import { nativeState } from '../shared/native-state'; -test('fireEvent calls handlers of the selected event system', async () => { - const onPointerDown = jest.fn(); - const onParentPointerDownCapture = jest.fn(); +// Runs in both event systems, through the public `fireEvent`. Where they differ, tests branch on +// `isModern()`, with a comment saying which one matches React Native. + +const emptyEvent = { nativeEvent: {} }; +const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; +const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; +const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } }; +const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; + +const defaultTouchPayload = { + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: expect.any(Number), + touches: [], +}; + +beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); +}); + +function isModern() { + return getConfig().unstable_eventSystem === 'modern'; +} + +/** Error message `fireEvent` rejects with, or `undefined` when it resolves. */ +async function getErrorMessage(promise: Promise) { + try { + await promise; + return undefined; + } catch (error) { + return (error as Error).message; + } +} + +test('fireEvent accepts event name with or without "on" prefix', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('btn'), 'press', emptyEvent); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(screen.getByTestId('btn'), 'onPress', emptyEvent); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('fireEvent does not call handler props without the "on" prefix (legacy: calls them for unprefixed event names)', async () => { + const press = jest.fn(); + const testOnlyPress = jest.fn(); + // @ts-expect-error Intentionally passing such props + await render(); + + await fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); + expect(press).not.toHaveBeenCalled(); + expect(testOnlyPress).not.toHaveBeenCalled(); + + // Modern matches React Native, where only Pressability calls press callbacks. Legacy calls any + // `press` prop. + await fireEvent(screen.getByTestId('view'), 'press', emptyEvent); + expect(press).toHaveBeenCalledTimes(isModern() ? 0 : 1); +}); + +test('fireEvent passes event data to handler', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn'), pressEventData); + expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); +}); + +test('fireEvent throws with more than one event argument (legacy: passes them all to the handler)', async () => { + const handlePress = jest.fn(); + await render(); + const error = await getErrorMessage( + fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'), + ); + // Modern matches React Native, where handlers receive a single event. + expect(error).toBe( + isModern() + ? 'Unable to fire a "press" event. Expected a single event object, received 3 arguments.' + : undefined, + ); + expect(handlePress.mock.calls).toEqual(isModern() ? [] : [['param1', 'param2', 'param3']]); +}); + +// React Native has no default actions for `preventDefault()` to cancel, so modern has nothing to +// resolve to. +test.each([ + { + name: 'fireEvent.press()', + ui: (handler: jest.Mock) => , + fire: (subject: TestInstance) => fireEvent.press(subject), + legacyResult: undefined, + }, + { + name: 'fireEvent() with a native event', + ui: (handler: jest.Mock) => , + fire: (subject: TestInstance) => fireEvent(subject, 'focus', emptyEvent), + legacyResult: 'result', + }, + { + name: 'fireEvent() with a Pressability event', + ui: (handler: jest.Mock) => , + fire: (subject: TestInstance) => fireEvent(subject, 'longPress', emptyEvent), + legacyResult: 'result', + }, + { + name: 'fireEvent.changeText()', + ui: (handler: jest.Mock) => ( + + ), + fire: (subject: TestInstance) => fireEvent.changeText(subject, 'Hello'), + legacyResult: 'result', + }, +])( + '$name resolves to undefined when the handler calls preventDefault() and returns a value (legacy: to the handler result)', + async ({ ui, fire, legacyResult }) => { + // Legacy passes event objects as given, which may have no `preventDefault()`. + const handler = jest.fn((event: unknown) => { + (event as Partial | undefined)?.preventDefault?.(); + return 'result'; + }); + await render(ui(handler)); + + await expect(fire(screen.getByTestId('subject'))).resolves.toBe( + isModern() ? undefined : legacyResult, + ); + expect(handler).toHaveBeenCalled(); + }, +); + +test('fireEvent bubbles event to parent handler', async () => { + const onPress = jest.fn(); await render( - + + Press me + , + ); + await fireEvent.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); +}); + +test('fireEvent dispatches bubbling events through capture and bubble phases (legacy: calls only the nearest handler)', async () => { + const calls: string[] = []; + const onPointerDown = jest.fn(() => calls.push('target.onPointerDown')); + await render( + calls.push('parent.onPointerDownCapture')} + onPointerDown={() => calls.push('parent.onPointerDown')} + > , ); - const init = { nativeEvent: { pointerId: 1 } }; - const result = await fireEvent(screen.getByTestId('target'), 'pointerDown', init); - const isModern = getConfig().unstable_eventSystem === 'modern'; - // Modern dispatches a `SyntheticEvent` through capture and bubble phases. Legacy calls only the - // target prop with the passed arguments. - expect(result).toBe(isModern ? true : undefined); - expect(onParentPointerDownCapture).toHaveBeenCalledTimes(isModern ? 1 : 0); - expect(onPointerDown.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); - expect(onPointerDown.mock.calls[0][0].nativeEvent).toEqual({ pointerId: 1 }); + await fireEvent(screen.getByTestId('target'), 'pointerDown', { nativeEvent: { pointerId: 1 } }); + + // Modern matches React Native, which dispatches a `SyntheticEvent` through capture and bubble + // phases. Legacy calls only the target prop with the passed arguments. + expect(calls).toEqual( + isModern() + ? ['parent.onPointerDownCapture', 'target.onPointerDown', 'parent.onPointerDown'] + : ['target.onPointerDown'], + ); + const event = (onPointerDown.mock.calls[0] as unknown[])[0] as SyntheticEvent; + expect(event instanceof SyntheticEvent).toBe(isModern()); + expect(event.nativeEvent).toEqual({ pointerId: 1 }); }); -test('fireEvent with an event type React Native does not dispatch natively', async () => { - const onChangeText = jest.fn(); - await render(); +test('fireEvent does not call props of composite components (legacy: calls them)', async () => { + const onCompositeFocus = jest.fn(); + function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) { + return {children}; + } + + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + // Modern matches React Native, which dispatches events only to host elements. + expect(onCompositeFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1); +}); + +describe('event object', () => { + test('throws without an event object (legacy: calls the handler without arguments)', async () => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus')); + + // Modern matches React Native, where handlers always receive an event. + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[]]); + }); + + test('throws without an event object for Pressability events (legacy: calls the callback without arguments)', async () => { + const onPressIn = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('pressable'), 'pressIn')); + + expect(error).toBe( + isModern() + ? `Unable to fire a "pressIn" event. Please provide an event object, e.g. fireEvent(element, 'pressIn', { nativeEvent: {} }).` + : undefined, + ); + expect(onPressIn.mock.calls).toEqual(isModern() ? [] : [[]]); + }); + + test.each([ + ['text', 'string "text"'], + [42, 'number 42'], + [null, 'null'], + [undefined, 'undefined'], + [[{ nativeEvent: {} }], 'an array'], + [new Date(0), 'an instance of Date'], + [() => {}, 'a function'], + ])( + 'throws when the event is %p (legacy: passes it to the handler)', + async (event, description) => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus', event)); + + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Expected an event object, received ${description}.` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]); + }, + ); + + test.each([ + [{ target: 1 }, '"target". "target" is set by the dispatch.'], + [ + { target: {}, currentTarget: { measure: () => {} } }, + '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', + ], + [{ persist: () => {} }, '"persist". "persist" is provided by the event.'], + [ + { preventDefault: () => {}, dispatchConfig: {} }, + '"preventDefault", "dispatchConfig". "preventDefault", "dispatchConfig" are provided by the event.', + ], + [{ text: 'Hello' }, '"text". Pass "text" in "nativeEvent" instead.'], + [ + { text: 'Hello', target: 1, persist: () => {} }, + '"text", "target", "persist". "target" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.', + ], + ])( + 'throws on unsupported keys %p (legacy: passes them to the handler)', + async (keys, message) => { + const onFocus = jest.fn(); + await render(); + + const event = { nativeEvent: {}, ...keys }; + const error = await getErrorMessage(fireEvent(screen.getByTestId('input'), 'focus', event)); - const error = await fireEvent(screen.getByTestId('input'), 'changeText', 'Hello').then( - () => undefined, - (e: Error) => e.message, + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Unsupported event object keys: ${message}` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]); + }, ); - const isModern = getConfig().unstable_eventSystem === 'modern'; + + test('throws when nativeEvent is not an object (legacy: passes it to the handler)', async () => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('input'), 'focus', { nativeEvent: 'text' }), + ); + + expect(error).toBe( + isModern() + ? 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".' + : undefined, + ); + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test.each([ + ['123', 'string "123"'], + [Number.NaN, 'number NaN'], + [Infinity, 'number Infinity'], + [null, 'null'], + ])( + 'throws when timeStamp is %p (legacy: passes it to the handler)', + async (timeStamp, description) => { + const onFocus = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('input'), 'focus', { timeStamp }), + ); + + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.` + : undefined, + ); + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test('passes timeStamp to handlers as event.timeStamp', async () => { + const onFocus = jest.fn(); + await render(); + + const nativeEvent = { timestamp: 100 }; + await fireEvent(screen.getByTestId('input'), 'focus', { nativeEvent, timeStamp: 42 }); + + const event = onFocus.mock.calls[0][0]; + expect(event.timeStamp).toBe(42); + expect(event.nativeEvent).toBe(nativeEvent); + expect(nativeEvent).toEqual({ timestamp: 100 }); + }); + + test('passes nativeEvent to handlers in an event targeting the element (legacy: passes the event object as given)', async () => { + const onSubmitEditing = jest.fn(); + await render(); + + const nativeEvent = { text: 'Hello', target: 1 }; + await fireEvent(screen.getByTestId('input'), 'submitEditing', { nativeEvent }); + + expect(onSubmitEditing).toHaveBeenCalledTimes(1); + const event = onSubmitEditing.mock.calls[0][0]; + expect(event.nativeEvent).toBe(nativeEvent); + // Modern matches React Native, which passes a `SyntheticEvent`. + expect(event.type).toBe(isModern() ? 'submitediting' : undefined); + expect(event.target).toBe(isModern() ? screen.getByTestId('input') : undefined); + }); + + test('uses an empty nativeEvent when the event object has none (legacy: passes the event object as given)', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'focus', {}); + + expect(onFocus.mock.calls[0][0].nativeEvent).toEqual(isModern() ? {} : undefined); + }); + + test('accepts an event object without a prototype', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'focus', Object.create(null)); + + expect(onFocus).toHaveBeenCalledTimes(1); + }); +}); + +describe('fireEvent.press', () => { + test('passes a SyntheticEvent with default touch payload to handler (legacy: a plain event object)', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn')); + expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + } + `); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes a plain object + // with stubs of its methods. + expect(onPress.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern()); + if (isModern()) return; + expect({ ...onPress.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(` + { + "currentTarget": { + "measure": [Function], + }, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('passes a press event from the pressed element to the responder (legacy: a plain event object)', async () => { + let event: SyntheticEvent | undefined; + let currentTarget: unknown; + let eventPhase: number | undefined; + await render( + { + event = e as unknown as SyntheticEvent; + currentTarget = event.currentTarget; + eventPhase = event.eventPhase; + }} + > + Press me + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(event?.nativeEvent).toEqual(defaultTouchPayload); + // Modern matches React Native: the event targets the pressed element, and Pressability calls + // `onPress` of the responder. Legacy passes stubs as `target` and `currentTarget`. + if (!isModern()) return; + expect(event?.type).toBe('press'); + expect(event?.target).toBe(screen.getByText('Press me')); + expect(currentTarget).toBe(screen.getByTestId('pressable')); + expect(eventPhase).toBe(event?.BUBBLING_PHASE); + // Reset after the dispatch. + expect(event?.currentTarget).toBeNull(); + }); + + test('overrides default event properties with passed event props', async () => { + const onPress = jest.fn(); + await render(); + const customEventData = { nativeEvent: { pageX: 20, pageY: 30 }, timeStamp: 123 }; + await fireEvent.press(screen.getByTestId('btn'), customEventData); + expect(onPress.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 20, + "pageY": 30, + "target": 0, + "timestamp": 100100100100, + "touches": [], + } + `); + expect(onPress.mock.calls[0][0].timeStamp).toBe(123); + }); + + test('throws on unsupported event object keys (legacy: merges them into the event)', async () => { + const onPress = jest.fn(); + await render(); + const pressable = screen.getByTestId('pressable'); + + const error = await getErrorMessage(fireEvent.press(pressable, { persist: jest.fn() })); + // Modern matches React Native, where the event provides its own methods. + expect(error).toBe( + isModern() + ? 'Unable to fire a "press" event. Unsupported event object keys: "persist". "persist" is provided by the event.' + : undefined, + ); + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + + await fireEvent.press(pressable, undefined); + expect(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 2); + }); + + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ['TouchableHighlight', TouchableHighlight], + ['TouchableWithoutFeedback', TouchableWithoutFeedback], + ['TouchableNativeFeedback', TouchableNativeFeedback], + ])('works on %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions + + Press me + , + ); + await fireEvent.press(screen.getByTestId('subject')); + expect(onPress).toHaveBeenCalled(); + }); + + test('calls only onPress, not onPressIn, onPressOut or onLongPress', async () => { + const calls: string[] = []; + await render( + calls.push('onPress')} + onPressIn={() => calls.push('onPressIn')} + onPressOut={() => calls.push('onPressOut')} + onLongPress={() => calls.push('onLongPress')} + />, + ); + + await fireEvent.press(screen.getByTestId('pressable')); + + expect(calls).toEqual(['onPress']); + }); + + test('calls onPress of host elements that have it, the innermost only', async () => { + const onInnerPress = jest.fn(); + const onOuterPress = jest.fn(); + await render( + + Outer Inner + , + ); + + await fireEvent.press(screen.getByText('Inner')); + + expect(onInnerPress).toHaveBeenCalledTimes(1); + expect(onOuterPress).not.toHaveBeenCalled(); + // Modern resets the event after the dispatch, as React Native does. + if (!isModern()) return; + expect(onInnerPress.mock.calls[0][0].currentTarget).toBeNull(); + }); + + test('calls onPress of editable TextInput', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent.press(screen.getByTestId('input')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('works with testOnly_onPress handlers', async () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onLongPress = jest.fn(); + const testOnlyPressProps = { + testOnly_onPress: onPress, + testOnly_onPressIn: onPressIn, + testOnly_onPressOut: onPressOut, + testOnly_onLongPress: onLongPress, + }; + + await render(); + + const subject = screen.getByTestId('subject'); + + await fireEvent.press(subject); + expect(onPress).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressIn', emptyEvent); + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'pressOut', emptyEvent); + expect(onPressOut).toHaveBeenCalledTimes(1); + + await fireEvent(subject, 'longPress', emptyEvent); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + test('renders state updates from onPress', async () => { + const Counter = () => { + const [count, setCount] = React.useState(0); + return ( + setCount(count + 1)}> + Count: {count} + + ); + }; + await render(); + + await fireEvent.press(screen.getByText('Count: 0')); + + expect(screen.getByText('Count: 1')).toBeOnTheScreen(); + }); + + test('rethrows onPress error after rendering state updates (legacy: without rendering them)', async () => { + const Counter = () => { + const [count, setCount] = React.useState(0); + return ( + { + setCount(count + 1); + throw new Error('Press error'); + }} + > + Count: {count} + + ); + }; + await render(); + + await expect(fireEvent.press(screen.getByText('Count: 0'))).rejects.toThrow('Press error'); + // Modern rethrows after `act()`, so React renders the update first. Legacy throws inside it. + expect(screen.getByText(isModern() ? 'Count: 1' : 'Count: 0')).toBeOnTheScreen(); + }); +}); + +describe('fireEvent.changeText', () => { + function logChangeHandlers(calls: string[], id: string) { + return { + onChange: () => calls.push(`${id}.onChange`), + onChangeCapture: () => calls.push(`${id}.onChangeCapture`), + }; + } + + test('fires change event with the text, then calls onChangeText (legacy: calls only onChangeText)', async () => { + const calls: string[] = []; + const onChange = jest.fn((_event: unknown) => calls.push('onChange')); + const onChangeText = jest.fn((_text: string) => calls.push('onChangeText')); + await render(); + const input = screen.getByTestId('input'); + + await fireEvent.changeText(input, 'Hello'); + + // Modern matches React Native, where TextInput calls `onChangeText` from its `onChange`. + expect(calls).toEqual(isModern() ? ['onChange', 'onChangeText'] : ['onChangeText']); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(nativeState.valueForInstance.get(input)).toBe('Hello'); + if (!isModern()) return; + const event = onChange.mock.calls[0][0] as SyntheticEvent; + expect(event.type).toBe('change'); + expect(event.target).toBe(input); + expect(event.nativeEvent).toEqual({ + text: 'Hello', + target: 0, + eventCount: 0, + selection: { start: 5, end: 5 }, + }); + }); + + test('calls onChangeText after the input onChange, before ancestors onChange (legacy: calls only onChangeText)', async () => { + const calls: string[] = []; + await render( + + calls.push('input.onChangeText')} + /> + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + // Modern matches React Native, where `change` goes through capture and bubble phases, and + // TextInput calls `onChangeText` from its `onChange`. + expect(calls).toEqual( + isModern() + ? [ + 'parent.onChangeCapture', + 'input.onChangeCapture', + 'input.onChange', + 'input.onChangeText', + 'parent.onChange', + ] + : ['input.onChangeText'], + ); + }); + + test('does not call onChangeText of an ancestor (legacy: calls it)', async () => { + const onChangeText = jest.fn(); + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + // Modern matches React Native, where only TextInput calls its own `onChangeText`. + expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('does not call onChange or onChangeText when a capture handler stops propagation (legacy: calls onChangeText)', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + event.stopPropagation() }}> + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + // Modern matches React Native. Legacy calls no capture handlers. + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('calls onChangeText when the input onChange stops propagation', async () => { + const onParentChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + + event.stopPropagation()} + onChangeText={onChangeText} + /> + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(onParentChange).not.toHaveBeenCalled(); + }); + + test('does not call onChangeText when the input onChange throws (legacy: does not call onChange)', async () => { + const onChangeText = jest.fn(); + await render( + { + throw new Error('Change error'); + }} + onChangeText={onChangeText} + />, + ); + + const error = await getErrorMessage(fireEvent.changeText(screen.getByTestId('input'), 'Hello')); + + expect(error).toBe(isModern() ? 'Change error' : undefined); + expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('saves value of uncontrolled TextInput before handlers run', async () => { + let value; + const onChangeText = jest.fn(() => { + value = nativeState.valueForInstance.get(screen.getByTestId('input')); + }); + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(value).toBe('Hello'); + expect(screen.getByTestId('input')).toHaveDisplayValue('Hello'); + }); + + test('renders state updates from handlers', async () => { + function Subject() { + const [value, setValue] = React.useState(''); + return ; + } + + await render(); + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(screen.getByTestId('input')).toHaveDisplayValue('Hello'); + }); + + test('does not fire on non-editable TextInput', async () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + await render( + , + ); + const input = screen.getByTestId('input'); + + await fireEvent.changeText(input, 'Hello'); + + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + }); + + test('throws on elements other than TextInput (legacy: calls onChangeText of any element)', async () => { + const onChangeText = jest.fn(); + await render(); + + const error = await getErrorMessage(fireEvent.changeText(screen.getByTestId('view'), 'Hello')); + + // Modern matches React Native, where only TextInput calls `onChangeText`. + expect(error).toBe( + isModern() + ? 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "View".' + : undefined, + ); + expect(onChangeText).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('throws when text is not a string (legacy: passes it to onChangeText)', async () => { + const onChangeText = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent.changeText(screen.getByTestId('input'), 5 as unknown as string), + ); + + expect(error).toBe( + isModern() + ? 'Unable to fire a "changeText" event. Expected text to be a string, received number 5.' + : undefined, + ); + expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [[5]]); + }); + + test('throws when fired as `onChangeText` (legacy: calls it and updates native state)', async () => { + const onChangeText = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + const error = await getErrorMessage(fireEvent(input, 'onChangeText', 'new text')); + // Modern matches React Native: `changeText` isn't a native event, TextInput calls + // `onChangeText` from its `onChange`. Use `fireEvent.changeText()` in both. + expect(error).toBe( + isModern() + ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.` + : undefined, + ); + expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [['new text']]); + expect(nativeState.valueForInstance.get(input)).toBe(isModern() ? undefined : 'new text'); + }); + + test('throws when fired as `changeText` with an event object (legacy: calls onChangeText)', async () => { + const onChangeText = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('input'), 'changeText', emptyEvent), + ); + + // Modern checks the event type before the event object, to point to `fireEvent.changeText()`. + expect(error).toBe( + isModern() + ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.` + : undefined, + ); + expect(onChangeText.mock.calls).toEqual(isModern() ? [] : [[emptyEvent]]); + }); +}); + +describe('native state', () => { + test.each(['change', 'onChange'])( + '%s event saves value of TextInput in native state', + async (eventType) => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, eventType, { nativeEvent: { text: 'new text' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }, + ); + + test('change event without text or on non-editable TextInput does not save value', async () => { + await render( + <> + + + , + ); + const input = screen.getByTestId('input'); + const nonEditable = screen.getByTestId('non-editable'); + + await fireEvent(input, 'change', emptyEvent); + await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } }); + + expect(nativeState.valueForInstance.get(input)).toBeUndefined(); + expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined(); + }); + + test('is saved before handlers run', async () => { + let contentOffset; + const onScroll = jest.fn(() => { + contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll')); + }); + await render(); + + await fireEvent(screen.getByTestId('scroll'), 'scroll', verticalScrollEvent); + + expect(contentOffset).toEqual({ x: 0, y: 200 }); + }); +}); + +describe('fireEvent.scroll', () => { + test('passes a SyntheticEvent with default scroll payload to handler (legacy: a plain event object)', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView); + expect(onScroll.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 0, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + } + `); + // Modern matches React Native, which passes a direct `SyntheticEvent`. Legacy passes a plain + // object with stubs of its methods. + expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern()); + expect(onScroll.mock.calls[0][0].type).toBe(isModern() ? 'scroll' : undefined); + expect(onScroll.mock.calls[0][0].rnIsDirect).toBe(isModern() ? true : undefined); + if (isModern()) return; + expect({ ...onScroll.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(` + { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('overrides default event properties with passed event props', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + const customEventData = { + nativeEvent: { contentOffset: { x: 50, y: 200 } }, + timeStamp: 123, + }; + await fireEvent.scroll(scrollView, customEventData); + expect(onScroll.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 50, + "y": 200, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + } + `); + expect(onScroll.mock.calls[0][0].timeStamp).toBe(123); + }); + + test('throws on unsupported event object keys (legacy: merges them into the event)', async () => { + const onScroll = jest.fn(); + await render(); + + const error = await getErrorMessage( + fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} }), + ); + + // Modern matches React Native, where the event provides its own methods. + expect(error).toBe( + isModern() + ? 'Unable to fire a "scroll" event. Unsupported event object keys: "persist". "persist" is provided by the event.' + : undefined, + ); + expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('works on ScrollView', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, verticalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + test('updates native state when fired with `on*` prefixed name', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, 'onScroll', verticalScrollEvent); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + test.each([ + ['onScroll', 'scroll'], + ['onScrollBeginDrag', 'scrollBeginDrag'], + ['onScrollEndDrag', 'scrollEndDrag'], + ['onMomentumScrollBegin', 'momentumScrollBegin'], + ['onMomentumScrollEnd', 'momentumScrollEnd'], + ])('fires %s on ScrollView', async (propName, eventType) => { + const handler = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, eventType, verticalScrollEvent); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toMatchObject(verticalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 200, + }); + }); + + test('without contentOffset scrolls to (0, 0)', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, {}); + expect(onScroll.mock.calls[0][0]).toMatchObject({ + nativeEvent: { contentOffset: { x: 0, y: 0 } }, + }); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('with non-finite contentOffset values uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { y: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('with horizontal scroll updates native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, horizontalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 50, + y: 0, + }); + }); + + test('without contentOffset via fireEvent() does not update native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); + }); + + test('with non-finite x contentOffset value uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ + x: 0, + y: 0, + }); + }); + + test('uses layout size from previous layout event as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 390, + height: 750, + }); + }); + + test('prefers passed layoutMeasurement over layout size from layout event', async () => { + const onScroll = jest.fn(); + await render( {}} />); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent.layout(scrollView, { width: 390, height: 750 }); + await fireEvent.scroll(scrollView, { + nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, + }); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 100, + height: 200, + }); + }); + + test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { + const onScroll = jest.fn(); + await render( + + {/* Spread because `View` types don't include `onScroll`. */} + {}} {...{ onScroll }} /> + , + ); + const content = screen.getByTestId('content'); + + await fireEvent.layout(content, { width: 390, height: 750 }); + await fireEvent.scroll(content); + + expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ + width: 0, + height: 0, + }); + }); +}); + +describe('fireEvent.layout', () => { + test('passes a SyntheticEvent with default layout payload to handler (legacy: a plain event object)', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout.mock.calls[0][0].nativeEvent).toMatchInlineSnapshot(` + { + "layout": { + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + "target": 0, + } + `); + // Modern matches React Native, which passes a direct `SyntheticEvent`. Legacy passes a plain + // object with stubs of its methods. + expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern()); + expect(onLayout.mock.calls[0][0].type).toBe(isModern() ? 'layout' : undefined); + expect(onLayout.mock.calls[0][0].rnIsDirect).toBe(isModern() ? true : undefined); + if (isModern()) return; + expect({ ...onLayout.mock.calls[0][0], nativeEvent: undefined }).toMatchInlineSnapshot(` + { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": undefined, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + } + `); + }); + + test('merges the passed layout onto the zeroed rectangle', async () => { + const onLayout = jest.fn(); + await render(); + + await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); + + expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ + layout: { x: 0, y: 0, width: 200, height: 80 }, + target: 0, + }); + }); + + test('uses zero for layout fields passed as undefined', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + + , + ); + const withoutHandler = screen.getByTestId('without-handler'); + + await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); + await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); + + expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('does not bubble to the handler on an ancestor element', async () => { + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); + + expect(onLayout).not.toHaveBeenCalled(); + }); + + test('does not bubble when fired as generic layout event (legacy: bubbles with a warning)', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onLayout = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); + await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); + + // Modern matches React Native: layout is a direct event, so it doesn't bubble. Legacy still + // bubbles it, with a warning. + expect(onLayout).toHaveBeenCalledTimes(isModern() ? 0 : 2); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + warnSpy.mockRestore(); + }); + + test('does not warn when layout size is saved in native state without onLayout handler', async () => { + configure({ eventDiagnostics: true }); + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('is not blocked by element responder rejecting touches', async () => { + const onLayout = jest.fn(); + await render( + false} />, + ); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).toHaveBeenCalledTimes(1); + }); + + test('saves layout size in native state', async () => { + await render( {}} />); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + + await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); + }); + + test('saves layout size in native state even without onLayout handler', async () => { + await render(); + const view = screen.getByTestId('view'); + + await fireEvent.layout(view, { width: 100, height: 80 }); + + expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); + }); + + test('does not call onLayout of composite component that does not forward it', async () => { + const onLayout = jest.fn(); + const Box = (_props: { onLayout: () => void }) => ; + await render(); + + await fireEvent.layout(screen.getByTestId('view')); + + expect(onLayout).not.toHaveBeenCalled(); + }); +}); + +describe('direct events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + type DirectEventCase = { + name: string; + eventType: string; + ui: (handler: jest.Mock) => React.ReactElement; + }; + + const directEventCases: DirectEventCase[] = [ + { + name: 'layout from View content', + eventType: 'layout', + ui: (handler) => ( + + + + ), + }, + { + name: 'accessibilityAction from Pressable content', + eventType: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), + }, + ...buildDirectEventCases( + 'View content', + ['accessibilityTap', 'magicTap', 'accessibilityEscape'], + (handlerProps) => ( + + + + ), + ), + { + name: 'textLayout from nested Text', + eventType: 'textLayout', + ui: (handler) => ( + + Nested + + ), + }, + ...buildDirectEventCases( + 'TextInput content', + ['scroll', 'selectionChange', 'contentSizeChange'], + (handlerProps) => ( + + Nested + + ), + ), + ...buildDirectEventCases( + 'Image content', + ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], + // Image does not accept children, clone it to fire the event on a nested element. + (handlerProps) => + React.cloneElement( + , + {}, + Nested, + ), + ), + ...buildDirectEventCases( + 'ScrollView content', + [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'scrollToTop', + 'contentSizeChange', + ], + (handlerProps) => ( + + + + ), + ), + { + name: 'scroll from TextInput to ancestor ScrollView', + eventType: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'refresh from FlatList item', + eventType: 'refresh', + ui: (handler) => ( + {item}} + refreshing={false} + onRefresh={handler} + /> + ), + }, + { + name: 'contentSizeChange from FlatList item', + eventType: 'contentSizeChange', + ui: (handler) => ( + {item}} + onContentSizeChange={handler} + /> + ), + }, + ...buildDirectEventCases( + 'Modal content', + ['requestClose', 'show', 'dismiss', 'orientationChange'], + (handlerProps) => ( + + Content + + ), + ), + ]; + + // Builds one case per event type, passing the matching `on*` handler prop to `ui`. + function buildDirectEventCases( + source: string, + eventTypes: string[], + ui: (handlerProps: Record) => React.ReactElement, + ): DirectEventCase[] { + return eventTypes.map((eventType) => ({ + name: `${eventType} from ${source}`, + eventType, + ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), + })); + } + + test.each(directEventCases)( + 'does not bubble $name (legacy: bubbles with a warning)', + async ({ eventType, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('target'), eventType, emptyEvent); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a + // warning. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test.each(directEventCases)( + 'does not warn for $name when fired on the emitting element', + async ({ eventType, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('emitter'), eventType, emptyEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + test('calls only the handler of the target when its ancestor has one too', async () => { + const onParentLayout = jest.fn(); + const onLayout = jest.fn(); + await render( + + + , + ); + + const nativeEvent = { layout: { x: 0, y: 0, width: 100, height: 50 } }; + await fireEvent(screen.getByTestId('target'), 'layout', { nativeEvent }); + + expect(onLayout).toHaveBeenCalledTimes(1); + expect(onLayout.mock.calls[0][0].nativeEvent).toBe(nativeEvent); + expect(onParentLayout).not.toHaveBeenCalled(); + }); + + test('does not bubble when fired with "on" prefixed event name (legacy: bubbles with a warning)', async () => { + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a + // warning. + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + test('does not bubble scroll (legacy: warns about stopping bubbling in the next major version)', async () => { + const onScroll = jest.fn(); + await render( + + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with a + // warning. + expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + if (isModern()) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " â–² fireEvent: "scroll" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. Fire it on: + + + " + `); + }); + + test.each([true, false])( + 'does not bubble regardless of eventDiagnostics (%s) (legacy: always warns about bubbling)', + async (eventDiagnostics) => { + configure({ eventDiagnostics }); + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + // Modern matches React Native: direct events don't bubble. Legacy still bubbles them, with + // a warning. Modern warns that the child has no handler, when `eventDiagnostics` is on. + const bubblingWarnings = warnSpy.mock.calls.filter(([message]) => + message.includes('"scroll" does not bubble in React Native'), + ); + const unhandledWarnings = warnSpy.mock.calls.filter(([message]) => + message.includes('No "onScroll" handler found on the element.'), + ); + expect(bubblingWarnings).toHaveLength(isModern() ? 0 : 1); + expect(unhandledWarnings).toHaveLength(isModern() && eventDiagnostics ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModern() && !eventDiagnostics ? 0 : 1); + }, + ); + + test('does not call handler on composite component above the emitting element (legacy: calls it with a warning)', async () => { + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + // Modern matches React Native: scroll doesn't bubble, and composite props are never called. + // Legacy calls the composite prop, with a warning. + expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); + + // Known gap: only the type of the element with the handler is checked. + test('does not call handler on an element that does not emit the event (legacy: calls it without a warning)', async () => { + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + // Modern matches React Native: scroll doesn't bubble, and composite props are never called. + expect(onScroll).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not call composite component handlers (legacy: calls them without a warning)', async () => { + const onLoad = jest.fn(); + const onShow = jest.fn(); + const Card = (_props: { onLoad: () => void; onShow: () => void }) => ( + + Card + + ); + await render(); + + await fireEvent(screen.getByText('Card'), 'load', emptyEvent); + await fireEvent(screen.getByText('Card'), 'show', emptyEvent); + + // Modern matches React Native: composite props are never called, and these events don't + // bubble. + expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onShow).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not bubble to host element that does not emit the event (legacy: bubbles without a warning)', async () => { + const onLoad = jest.fn(); + await render( + // @ts-expect-error View does not have onLoad prop + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load', emptyEvent); + + // Modern matches React Native: load is a direct event, so it doesn't bubble. + expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not bubble load event to ImageBackground handler (legacy: bubbles without a warning)', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load', emptyEvent); + + // Modern matches React Native: load doesn't bubble, and ImageBackground passes `onLoad` to its + // Image, which isn't an ancestor of the caption. + expect(onLoad).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('Pressability event types', () => { + function logCallbacks(calls: string[], id: string) { + const log = (name: string) => () => { + calls.push(`${id}.${name}`); + }; + return { + onPress: log('onPress'), + onPressIn: log('onPressIn'), + onPressOut: log('onPressOut'), + onLongPress: log('onLongPress'), + }; + } + + test.each([ + ['press', 'onPress'], + ['pressIn', 'onPressIn'], + ['pressOut', 'onPressOut'], + ['longPress', 'onLongPress'], + ['onPressIn', 'onPressIn'], + ])('"%s" calls only %s of the responder', async (eventType, name) => { + const calls: string[] = []; + await render( + <> + + Pressable + + + Touchable + + , + ); + + await fireEvent(screen.getByText('Pressable'), eventType, emptyEvent); + await fireEvent(screen.getByText('Touchable'), eventType, emptyEvent); + + expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); + }); + + test('passes a direct event with default touch payload (legacy: passes the event object as given)', async () => { + const onPressIn = jest.fn(); + await render( + + Press me + , + ); + + await fireEvent(screen.getByText('Press me'), 'pressIn', { + nativeEvent: { pageX: 20 }, + timeStamp: 123, + }); + + const event = onPressIn.mock.calls[0][0]; + // Modern matches React Native, which derives Pressability events from a touch. + expect(event.nativeEvent).toEqual( + isModern() ? { ...defaultTouchPayload, pageX: 20 } : { pageX: 20 }, + ); + expect(event.timeStamp).toBe(123); + if (!isModern()) return; + expect(event.type).toBe('pressin'); + expect(event.bubbles).toBe(false); + expect(event.rnIsDirect).toBe(true); + expect(event.target).toBe(screen.getByText('Press me')); + }); +}); + +test('fireEvent throws for custom event (onCustomEvent) on composite component (legacy: calls it)', async () => { + const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( + onCustomEvent('event data')}> + Custom + + ); + const handler = jest.fn(); + await render(); + const error = await getErrorMessage( + fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'), + ); + // Modern matches React Native, which dispatches only native events, to host elements. Legacy + // calls any matching prop, also on composite components. expect(error).toBe( - isModern - ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.` + isModern() + ? `Unable to fire a "customEvent" event. React Native doesn't dispatch "customEvent" natively, so no handler would be called.` : undefined, ); - expect(onChangeText.mock.calls).toEqual(isModern ? [] : [['Hello']]); + expect(handler.mock.calls).toEqual(isModern() ? [] : [['event data']]); }); -test('fireEvent.changeText', async () => { - const calls: string[] = []; - await render( - calls.push(`onChange: ${event.nativeEvent.text}`)} - onChangeText={(text) => calls.push(`onChangeText: ${text}`)} - />, +test('fireEvent throws for custom prop name (handlePress) on composite component (legacy: calls it)', async () => { + const MyButton = ({ handlePress }: { handlePress: () => void }) => ( + + Button + + ); + const handler = jest.fn(); + await render(); + const error = await getErrorMessage(fireEvent(screen.getByText('Button'), 'handlePress')); + // Modern matches React Native, which dispatches only native events, to host elements. Legacy + // calls any matching prop, also on composite components. + expect(error).toBe( + isModern() + ? `Unable to fire a "handlePress" event. React Native doesn't dispatch "handlePress" natively, so no handler would be called.` + : undefined, ); + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); +}); - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - expect(calls).toEqual( - getConfig().unstable_eventSystem === 'modern' - ? ['onChange: Hello', 'onChangeText: Hello'] - : ['onChangeText: Hello'], +test('fireEvent calls handler on element when both element and parent have handlers', async () => { + const childHandler = jest.fn(); + const parentHandler = jest.fn(); + await render( + + + Press me + + , ); + await fireEvent.press(screen.getByTestId('child')); + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); }); -test('fireEvent.press uses the legacy implementation', async () => { +test('fireEvent does nothing when element is unmounted', async () => { const onPress = jest.fn(); + const onFocus = jest.fn(); + const onChangeText = jest.fn(); await render( - - Press me - , + + + + + , ); + const button = screen.getByTestId('btn'); + const input = screen.getByTestId('input'); + const scrollView = screen.getByTestId('scroll'); + + await screen.rerender(); + await fireEvent.press(button); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent.changeText(input, 'Hello'); + await fireEvent.scroll(scrollView, verticalScrollEvent); + + expect(onPress).not.toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); +}); - expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); +test.each([ + ['fireEvent()', (instance: TestInstance) => fireEvent(instance, 'focus', emptyEvent), 'focus'], + ['fireEvent.press()', (instance: TestInstance) => fireEvent.press(instance), 'press'], + [ + 'fireEvent.changeText()', + (instance: TestInstance) => fireEvent.changeText(instance, 'Hello'), + 'changeText', + ], + ['fireEvent.scroll()', (instance: TestInstance) => fireEvent.scroll(instance), 'scroll'], + ['fireEvent.layout()', (instance: TestInstance) => fireEvent.layout(instance), 'layout'], +])('%s throws without an element', async (_, fire, eventType) => { + const error = await getErrorMessage(fire(null as unknown as TestInstance)); + expect(error).toBe( + isModern() + ? `Unable to fire a "${eventType}" event. Please provide a host element.` + : "Cannot read properties of null (reading 'parent')", + ); +}); + +test('fireEvent throws with non-existent event name (legacy: does nothing)', async () => { + await render(); + const element = screen.getByTestId('btn'); + const error = await getErrorMessage(fireEvent(element, 'nonExistentEvent' as any)); + // React Native never dispatches it, so no handler runs in either. Modern throws to point out + // the mistake. + expect(error).toBe( + isModern() + ? `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called.` + : undefined, + ); +}); + +test('fireEvent rejects with the handler error', async () => { + const error = new Error('Handler error'); + const onPress = jest.fn(() => { + throw error; + }); + const onFocus = jest.fn(() => { + throw error; + }); + await render( + <> + + + , + ); + await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); + await expect(fireEvent(screen.getByTestId('input'), 'focus', emptyEvent)).rejects.toThrow( + 'Handler error', + ); expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + expect(onFocus).toHaveBeenCalledTimes(1); }); -test('fireEvent.scroll and fireEvent.layout pass events of the selected event system', async () => { - const onScroll = jest.fn(); - const onLayout = jest.fn(); - await render(); +test('fireEvent renders state updates from the handler', async () => { + function Counter() { + const [count, setCount] = React.useState(0); + return ( + setCount((value) => value + 1)}> + Count: {count} + + ); + } + + await render(); + await fireEvent(screen.getByTestId('text'), 'press', emptyEvent); + + expect(screen.getByText('Count: 1')).toBeOnTheScreen(); +}); + +describe('disabled elements', () => { + test('does not fire on disabled Pressable', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not fire on disabled TouchableOpacity', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('bubbles event past disabled inner to enabled outer Pressable', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); + + test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); + + test('bubbles event past disabled Text and non-editable TextInput to the Pressable (legacy: calls onPress of disabled Text)', async () => { + const onTextPress = jest.fn(); + const onInputPress = jest.fn(); + const onPress = jest.fn(); + await render( + + + Disabled + + + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + await fireEvent.press(screen.getByTestId('input')); + + // Modern matches `userEvent.press()`, which skips disabled Text. Legacy checks only + // responders, and the mocked Text has none. + expect(onTextPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onInputPress).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalledTimes(isModern() ? 2 : 1); + }); + + test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { + const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( + + Trigger Test + + ); + const handlePress = jest.fn(); + await render(); + await fireEvent.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); + }); +}); + +describe('unhandled event warning', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + configure({ eventDiagnostics: true }); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + function getWarnings(): string[] { + return warnSpy.mock.calls.map(([message]) => message.split('\n')[0]); + } + + test('warns when the handler is on a disabled element', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on a disabled element. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + " + `); + }); + + test('warns when the handler is on a disabled TouchableOpacity', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(getWarnings()).toEqual(['Cannot fire the "press" event on a disabled element.']); + }); + + test('warns when the handler is on a disabled Text (legacy: calls it)', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + // Modern matches `userEvent.press()`, which skips disabled Text. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(getWarnings()).toEqual( + isModern() ? ['Cannot fire the "press" event on a disabled element.'] : [], + ); + }); + + test('warns when the pressIn callback is on a disabled element', async () => { + await render(); + + await fireEvent(screen.getByTestId('pressable'), 'pressIn', emptyEvent); + + expect(getWarnings()).toEqual(['Cannot fire the "pressIn" event on a disabled element.']); + }); + + test('lists every disabled element the event skipped', async () => { + await render( + + + Trigger + + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on disabled elements. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Trigger + + + + + + + Trigger + + + " + `); + }); + + test('warns when no element handles the event', async () => { + await render( + + Trigger + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onPress" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + Trigger + " + `); + }); + + test('warns when no element handles a native event', async () => { + await render( + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'focus', emptyEvent); + + expect(getWarnings()).toEqual(['No "onFocus" handler found on the element or its ancestors.']); + }); + + // Not `pressIn` or `pressOut`: `Pressable` always passes its own, which update the pressed state. + test('warns when no element has the longPress callback', async () => { + await render(); + + await fireEvent(screen.getByTestId('pressable'), 'longPress', emptyEvent); + + expect(getWarnings()).toEqual([ + 'No "onLongPress" handler found on the element or its ancestors.', + ]); + }); + + test('does not warn when only a capture handler is called (legacy: warns, as it calls no capture handlers)', async () => { + await render( + // Spread because `View` types include `onFocusCapture` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'focus', emptyEvent); + + // Modern matches React Native, which dispatches through the capture phase. + expect(getWarnings()).toEqual( + isModern() ? [] : ['No "onFocus" handler found on the element or its ancestors.'], + ); + }); + + test('does not warn when the event bubbles to an enabled parent', async () => { + await render( + + + Inner Trigger + + , + ); + + await fireEvent.press(screen.getByText('Inner Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { + await render( + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inner')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on elements blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + + + + + Blocked by: + + " + `); + }); + + test('reports pointerEvents rather than disabled when both block the handler', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('btn')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports the element that blocks with pointerEvents', async () => { + await render( + + + + + + , + ); + + await fireEvent.press(screen.getByTestId('inside-box-only')); + await fireEvent.press(screen.getByTestId('box-none')); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` + "Cannot fire the "press" event on an element blocked by pointerEvents. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + + + Blocked by: + + " + `); + }); + + test('reports non-editable TextInput for events not affected by pointerEvents', async () => { + await render( + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "changeText" event on a non-editable TextInput\./, + ); + }); + + test('does not warn when the responder declines the touch', async () => { + const onPress = jest.fn(); + // @ts-expect-error Host View does not declare `onPress`. + await render( false} onPress={onPress} />); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onPress).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when a responder without Pressability claims the touch', async () => { + function Draggable() { + const { panHandlers } = PanResponder.create({ onStartShouldSetPanResponder: () => true }); + return ( + + Trigger + + ); + } + await render( + + + , + ); + + await fireEvent.press(screen.getByText('Trigger')); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('warns when the handler is blocked by non-editable TextInput', async () => { + await render(); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "changeText" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('warns when "press" is blocked by non-editable TextInput', async () => { + await render(); + + await fireEvent(screen.getByTestId('input'), 'press', emptyEvent); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "press" event on a non-editable TextInput\./, + ); + }); + + test.each([ + ['focus', 'onFocus'], + ['blur', 'onBlur'], + ])( + 'does not warn when "%s" is fired on non-editable TextInput (legacy: warns it is blocked)', + async (eventType, handlerName) => { + const handler = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), eventType, emptyEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused. Modern + // `fireEvent()` dispatches the event as given, without checking `editable`. + expect(handler).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + if (isModern()) return; + expect(warnSpy.mock.calls[0][0]).toMatch( + new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), + ); + }, + ); + + test('names the non-editable TextInput when the handler is on its parent', async () => { + const onFocus = jest.fn(); + await render( + // Spread because `View` types include `onFocus` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused. Modern + // `fireEvent()` dispatches the event as given, without checking `editable`. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + if (isModern()) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "Cannot fire the "focus" event on a non-editable TextInput. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('reports disabled elements when they include a non-editable TextInput', async () => { + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /^Cannot fire the "press" event on disabled elements\./, + ); + }); + + test('warns when element has no onLayout handler and layout event has no layout', async () => { + await render(); + + await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + // Modern matches React Native: layout is a direct event, so ancestors aren't checked. + expect(warnSpy.mock.calls[0][0].split('\n')[0]).toBe( + isModern() + ? 'No "onLayout" handler found on the element.' + : 'No "onLayout" handler found on the element or its ancestors.', + ); + if (isModern()) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "No "onLayout" handler found on the element or its ancestors. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); + }); + + test('does not warn when the event updates native state', async () => { + await render( + <> + + + , + ); + + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + await fireEvent.scroll(screen.getByTestId('scroll'), verticalScrollEvent); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn on an unmounted element', async () => { + await render( + + + + + , + ); + const pressable = screen.getByTestId('pressable'); + const input = screen.getByTestId('input'); + const view = screen.getByTestId('view'); + await screen.rerender(); + + await fireEvent.press(pressable); + await fireEvent.changeText(input, 'Hello'); + await fireEvent(view, 'focus', emptyEvent); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when eventDiagnostics is turned off', async () => { + configure({ eventDiagnostics: false }); + await render( + + + Disabled + + No handler + + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + await fireEvent(screen.getByTestId('disabled'), 'pressIn', emptyEvent); + await fireEvent.press(screen.getByText('No handler')); + await fireEvent(screen.getByText('No handler'), 'focus', emptyEvent); + await fireEvent.layout(screen.getByText('No handler')); + await fireEvent.scroll(screen.getByText('No handler')); + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('pointerEvents prop', () => { + test('does not fire inside View with pointerEvents="none"', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByTestId('btn')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not fire inside View with pointerEvents="none" set in style', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByTestId('btn')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not fire inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires inside View with pointerEvents="box-none"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('fires inside View with pointerEvents="auto"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('does not fire deeply inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + + Trigger + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('view'), 'layout', layoutEvent); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires on Pressable with pointerEvents="box-only" on itself', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); + }); + + test('moves the touch from a child to the Pressable with pointerEvents="box-only" (legacy: passes a stub target)', async () => { + const onTextPress = jest.fn(); + const onPress = jest.fn(); + await render( + + Press me + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(onTextPress).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalledTimes(1); + // Modern matches React Native, where the touch targets the Pressable. + expect(onPress.mock.calls[0][0].target).toEqual( + isModern() ? screen.getByTestId('pressable') : {}, + ); + }); +}); + +describe('non-editable TextInput', () => { + function WrappedTextInput(props: TextInputProps) { + return ; + } + + function DoubleWrappedTextInput(props: TextInputProps) { + return ; + } + + test('dispatches focus and submitEditing, but not changeText (legacy: blocks touch-related events)', async () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the event as given, without checking `editable`. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent); + }); + + test('bubbles focus and submitEditing from nested Text child, but not layout (legacy: blocks touch-related events)', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + + Nested Text + , + ); + + const subject = screen.getByText('Nested Text'); + await fireEvent(subject, 'focus', emptyEvent); + await fireEvent(subject, 'onFocus', emptyEvent); + const changeTextError = await getErrorMessage(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); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the + // TextInput. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 2 : 0); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 2 : 0); + // Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it. + expect(changeTextError).toBe( + isModern() + ? 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "Text".' + : undefined, + ); + expect(onChangeText).not.toHaveBeenCalled(); + // Modern matches React Native: layout is a direct event, so it doesn't reach the parent + // TextInput. Legacy still bubbles it, with a warning. + expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent], [layoutEvent]]); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + warnSpy.mockRestore(); + }); + + test.each([ + ['WrappedTextInput', WrappedTextInput], + ['DoubleWrappedTextInput', DoubleWrappedTextInput], + ])( + 'dispatches focus and submitEditing on %s, but not changeText (legacy: blocks touch-related events)', + async (_, Component) => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + await render( + , + ); + + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); + + // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. + // Modern `fireEvent()` dispatches the event as given, without checking `editable`. + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent); + }, + ); + + test('fires layout event', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'layout', layoutEvent); + expect(onLayout).toHaveBeenCalled(); + }); + + test('fires scroll event', async () => { + const onScroll = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'scroll', verticalScrollEvent); + expect(onScroll).toHaveBeenCalled(); + }); +}); + +describe('responder system', () => { + test('respects disabled prop through composite wrappers', async () => { + function TestChildTouchableComponent({ + onPress, + someProp, + }: { + onPress: () => void; + someProp: boolean; + }) { + return ( + + + Trigger + + + ); + } + const handlePress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); + }); + + test('passes the touch to an ancestor when the responder declines it', async () => { + const onViewPress = jest.fn(); + const onPress = jest.fn(); + await render( + + false} + {...{ testOnly_onPress: onViewPress }} + /> + , + ); + + await fireEvent.press(screen.getByTestId('view')); + + expect(onViewPress).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('does not call onPress when a responder without Pressability claims the touch (legacy: calls onPress of the ancestor)', async () => { + const onPress = jest.fn(); + const onPanResponderGrant = jest.fn(); + const Draggable = () => { + const panResponder = React.useRef( + PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderGrant }), + ).current; + return ; + }; + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('draggable')); + + // Modern matches React Native, where the `PanResponder` keeps the touch from its ancestors. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onPanResponderGrant).not.toHaveBeenCalled(); + }); + + test('asks responders from the target to the root, without arguments (legacy: asks only responders with a handler)', async () => { + const calls: string[] = []; + const onStartShouldSetResponder = + (name: string) => + (...args: unknown[]) => { + calls.push(`${name} (${args.length} args)`); + return false; + }; + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('child')); + + // Modern matches React Native's responder negotiation. Legacy asks only the responder of the + // element with the handler, and neither has one. + expect(calls).toEqual(isModern() ? ['child (0 args)', 'parent (0 args)'] : []); + }); - const scrollResult = await fireEvent.scroll(screen.getByTestId('scrollView')); - const layoutResult = await fireEvent.layout(screen.getByTestId('scrollView')); - const isModern = getConfig().unstable_eventSystem === 'modern'; - expect(scrollResult).toBe(isModern ? true : undefined); - expect(layoutResult).toBe(isModern ? true : undefined); - expect(onScroll.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); - expect(onLayout.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); + test('throws for responderMove on PanResponder component (legacy: calls the handler)', async () => { + const onDrag = jest.fn(); + function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderMove: onDrag, + }).panHandlers; + return ( + + Trigger + + ); + } + await render(); + const error = await getErrorMessage( + fireEvent(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }), + ); + // Legacy calls `onResponderMove` directly. On a device, it follows a granted touch with the + // responder system's touch history, which modern doesn't simulate, so modern throws. + expect(error).toBe( + isModern() + ? `Unable to fire a "responderMove" event. React Native doesn't dispatch "responderMove" natively, so no handler would be called.` + : undefined, + ); + expect(onDrag).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }); }); -test('reads the event system on each call', async () => { +test('fireEvent reads the event system on each call', async () => { const onLayout = jest.fn(); await render(); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 4b2fc5fe5..5a51de7f2 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -32,34 +32,34 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + if (isModern()) { + await modernFireEvent.press(instance, eventProps as FireEventInit); + return; + } + await legacyFireEvent.press(instance, eventProps); }; -fireEvent.scroll = async ( - instance: TestInstance, - eventProps?: EventProps, -): Promise => { +fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { - return await modernFireEvent.scroll(instance, eventProps as FireEventInit); + await modernFireEvent.scroll(instance, eventProps as FireEventInit); + return; } await legacyFireEvent.scroll(instance, eventProps); - return undefined; }; fireEvent.layout = async ( instance: TestInstance, layout?: Partial, -): Promise => { +): Promise => { if (isModern()) { - return await modernFireEvent.layout(instance, layout); + await modernFireEvent.layout(instance, layout); + return; } await legacyFireEvent.layout(instance, layout); - return undefined; }; function isModern() { diff --git a/src/events/legacy/__tests__/fire-event.test.tsx b/src/events/legacy/__tests__/fire-event.test.tsx deleted file mode 100644 index ec5c1c74a..000000000 --- a/src/events/legacy/__tests__/fire-event.test.tsx +++ /dev/null @@ -1,1818 +0,0 @@ -import * as React from 'react'; -import type { TextInputProps } from 'react-native'; -import { - FlatList, - Image, - ImageBackground, - Modal, - PanResponder, - Pressable, - ScrollView, - Text, - TextInput, - TouchableHighlight, - TouchableNativeFeedback, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; - -import { fireEvent, render, screen } from '../../..'; -import { configure } from '../../../config'; -import { _console, logger } from '../../../helpers/logger'; -import { runInLegacyEventSystem } from '../../../test-utils/event-system'; -import { getEventHandlerName } from '../../shared/handler'; -import { nativeState } from '../../shared/native-state'; - -const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; -const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; -const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } }; -const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; - -runInLegacyEventSystem(); - -beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); -}); - -test('fireEvent accepts event name with or without "on" prefix', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('btn'), 'press'); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(screen.getByTestId('btn'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => { - const press = jest.fn(); - const testOnlyPress = jest.fn(); - // @ts-expect-error Intentionally passing such props - await render(); - - await fireEvent(screen.getByTestId('view'), 'onPress'); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); - - await fireEvent(screen.getByTestId('view'), 'press'); - expect(press).toHaveBeenCalledTimes(1); -}); - -test('fireEvent passes event data to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn'), pressEventData); - expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); -}); - -test('fireEvent passes multiple parameters to handler', async () => { - const handlePress = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'); - expect(handlePress).toHaveBeenCalledWith('param1', 'param2', 'param3'); -}); - -test('fireEvent.press returns undefined when event handler returns a value', async () => { - const handler = jest.fn().mockReturnValue('result'); - await render(); - const result = await fireEvent.press(screen.getByTestId('btn')); - expect(result).toBe(undefined); -}); - -test('fireEvent bubbles event to parent handler', async () => { - const onPress = jest.fn(); - await render( - - Press me - , - ); - await fireEvent.press(screen.getByText('Press me')); - expect(onPress).toHaveBeenCalled(); -}); - -describe('fireEvent.press', () => { - test('passes default press event object to handler', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('btn')); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onPress = jest.fn(); - await render(); - const customEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; - await fireEvent.press(screen.getByTestId('btn'), customEventData); - expect(onPress.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": { - "measure": [Function], - }, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 20, - "pageY": 30, - "target": 0, - "timestamp": 100100100100, - "touches": [], - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test.each([ - ['Pressable', Pressable], - ['TouchableOpacity', TouchableOpacity], - ['TouchableHighlight', TouchableHighlight], - ['TouchableWithoutFeedback', TouchableWithoutFeedback], - ['TouchableNativeFeedback', TouchableNativeFeedback], - ])('works on %s', async (_, Component) => { - const onPress = jest.fn(); - await render( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions - - Press me - , - ); - await fireEvent.press(screen.getByTestId('subject')); - expect(onPress).toHaveBeenCalled(); - }); - - test('works with testOnly_onPress handlers', async () => { - const onPress = jest.fn(); - const onPressIn = jest.fn(); - const onPressOut = jest.fn(); - const onLongPress = jest.fn(); - const testOnlyPressProps = { - testOnly_onPress: onPress, - testOnly_onPressIn: onPressIn, - testOnly_onPressOut: onPressOut, - testOnly_onLongPress: onLongPress, - }; - - await render(); - - const subject = screen.getByTestId('subject'); - - await fireEvent.press(subject); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressIn'); - expect(onPressIn).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'pressOut'); - expect(onPressOut).toHaveBeenCalledTimes(1); - - await fireEvent(subject, 'longPress'); - expect(onLongPress).toHaveBeenCalledTimes(1); - }); -}); - -describe('fireEvent.changeText', () => { - test('works on TextInput', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent.changeText(input, 'new text'); - expect(onChangeText).toHaveBeenCalledWith('new text'); - expect(nativeState.valueForInstance.get(input)).toBe('new text'); - }); - - test('updates native state when fired with `on*` prefixed name', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent(input, 'onChangeText', 'new text'); - expect(onChangeText).toHaveBeenCalledWith('new text'); - expect(nativeState.valueForInstance.get(input)).toBe('new text'); - }); - - test('does not fire on non-editable TextInput', async () => { - const onChangeText = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await fireEvent.changeText(input, 'new text'); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - }); -}); - -test('change event saves value of TextInput in native state', async () => { - await render(); - const input = screen.getByTestId('input'); - - await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } }); - - expect(nativeState.valueForInstance.get(input)).toBe('new text'); -}); - -describe('fireEvent.scroll', () => { - test('passes default scroll event object to handler', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView); - expect(onScroll.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "contentInset": { - "bottom": 0, - "left": 0, - "right": 0, - "top": 0, - }, - "contentOffset": { - "x": 0, - "y": 0, - }, - "contentSize": { - "height": 0, - "width": 0, - }, - "layoutMeasurement": { - "height": 0, - "width": 0, - }, - "responderIgnoreScroll": true, - "target": 0, - "velocity": { - "x": 0, - "y": 0, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('overrides default event properties with passed event props', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - const customEventData = { - nativeEvent: { contentOffset: { x: 50, y: 200 } }, - }; - await fireEvent.scroll(scrollView, customEventData); - expect(onScroll.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "contentInset": { - "bottom": 0, - "left": 0, - "right": 0, - "top": 0, - }, - "contentOffset": { - "x": 50, - "y": 200, - }, - "contentSize": { - "height": 0, - "width": 0, - }, - "layoutMeasurement": { - "height": 0, - "width": 0, - }, - "responderIgnoreScroll": true, - "target": 0, - "velocity": { - "x": 0, - "y": 0, - }, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('works on ScrollView', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, verticalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - test('updates native state when fired with `on*` prefixed name', async () => { - const onScroll = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, 'onScroll', verticalScrollEvent); - expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - test.each([ - ['onScroll', 'scroll'], - ['onScrollBeginDrag', 'scrollBeginDrag'], - ['onScrollEndDrag', 'scrollEndDrag'], - ['onMomentumScrollBegin', 'momentumScrollBegin'], - ['onMomentumScrollEnd', 'momentumScrollEnd'], - ])('fires %s on ScrollView', async (propName, eventType) => { - const handler = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, eventType, verticalScrollEvent); - expect(handler).toHaveBeenCalledWith(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - - test('without contentOffset scrolls to (0, 0)', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, {}); - expect(onScroll.mock.calls[0][0]).toMatchObject({ - nativeEvent: { contentOffset: { x: 0, y: 0 } }, - }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with non-finite contentOffset values uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { y: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('with horizontal scroll updates native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, horizontalScrollEvent); - expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 50, - y: 0, - }); - }); - - test('without contentOffset via fireEvent() does not update native state', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); - - test('with non-finite x contentOffset value uses 0', async () => { - const onScroll = jest.fn(); - await render( - - Content - , - ); - const scrollView = screen.getByTestId('scroll'); - await fireEvent.scroll(scrollView, { - nativeEvent: { contentOffset: { x: Infinity } }, - }); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 0, - }); - }); - - test('uses layout size from previous layout event as layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 390, - height: 750, - }); - }); - - test('prefers passed layoutMeasurement over layout size from layout event', async () => { - const onScroll = jest.fn(); - await render( {}} />); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView, { - nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 100, - height: 200, - }); - }); - - test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onScroll = jest.fn(); - await render( - - {}} /> - , - ); - const content = screen.getByTestId('content'); - - await fireEvent.layout(content, { width: 390, height: 750 }); - await fireEvent.scroll(content); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 0, - height: 0, - }); - warnSpy.mockRestore(); - }); -}); - -describe('fireEvent.layout', () => { - test('passes default layout event object to handler', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout.mock.calls[0][0]).toMatchInlineSnapshot(` - { - "currentTarget": {}, - "isDefaultPrevented": [Function], - "isPersistent": [Function], - "isPropagationStopped": [Function], - "nativeEvent": { - "layout": { - "height": 0, - "width": 0, - "x": 0, - "y": 0, - }, - "target": 0, - }, - "persist": [Function], - "preventDefault": [Function], - "stopPropagation": [Function], - "target": {}, - "timeStamp": 0, - } - `); - }); - - test('merges the passed layout onto the zeroed rectangle', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80 }); - - expect(onLayout.mock.calls[0][0].nativeEvent).toEqual({ - layout: { x: 0, y: 0, width: 200, height: 80 }, - target: 0, - }); - }); - - test('uses zero for layout fields passed as undefined', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - - , - ); - const withoutHandler = screen.getByTestId('without-handler'); - - await fireEvent.layout(screen.getByTestId('with-handler'), { x: undefined, width: undefined }); - await fireEvent.layout(withoutHandler, { width: undefined, height: undefined }); - - expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ - x: 0, - y: 0, - width: 0, - height: 0, - }); - expect(nativeState.layoutSizeForInstance.get(withoutHandler)).toEqual({ width: 0, height: 0 }); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - test('does not bubble to the handler on an ancestor element', async () => { - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('bubbles with a warning when fired as generic layout event', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); - await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); - - expect(onLayout).toHaveBeenCalledTimes(2); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test('does not warn when layout size is saved in native state without onLayout handler', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - test('warns when element has no onLayout handler and event has no layout', async () => { - configure({ eventDiagnostics: true }); - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - await render(); - - await fireEvent(screen.getByTestId('view'), 'layout', { nativeEvent: {} }); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " â–² No "onLayout" handler found on the element or its ancestors. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - " - `); - warnSpy.mockRestore(); - }); - - test('is not blocked by element responder rejecting touches', async () => { - const onLayout = jest.fn(); - await render( - false} />, - ); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).toHaveBeenCalledTimes(1); - }); - - test('saves layout size in native state', async () => { - await render( {}} />); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - - await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); - }); - - test('saves layout size in native state even without onLayout handler', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { width: 100, height: 80 }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - }); - - test('does not call onLayout of composite component that does not forward it', async () => { - const onLayout = jest.fn(); - const Box = (_props: { onLayout: () => void }) => ; - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).not.toHaveBeenCalled(); - }); -}); - -describe('direct events', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - type DirectEventCase = { - name: string; - eventType: string; - ui: (handler: jest.Mock) => React.ReactElement; - }; - - const directEventCases: DirectEventCase[] = [ - { - name: 'layout from View content', - eventType: 'layout', - ui: (handler) => ( - - - - ), - }, - { - name: 'accessibilityAction from Pressable content', - eventType: 'accessibilityAction', - ui: (handler) => ( - - Button - - ), - }, - ...buildDirectEventCases( - 'View content', - ['accessibilityTap', 'magicTap', 'accessibilityEscape'], - (handlerProps) => ( - - - - ), - ), - { - name: 'textLayout from nested Text', - eventType: 'textLayout', - ui: (handler) => ( - - Nested - - ), - }, - ...buildDirectEventCases( - 'TextInput content', - ['scroll', 'selectionChange', 'contentSizeChange'], - (handlerProps) => ( - - Nested - - ), - ), - ...buildDirectEventCases( - 'Image content', - ['loadStart', 'progress', 'partialLoad', 'load', 'error', 'loadEnd'], - // Image does not accept children, clone it to fire the event on a nested element. - (handlerProps) => - React.cloneElement( - , - {}, - Nested, - ), - ), - ...buildDirectEventCases( - 'ScrollView content', - [ - 'scroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - 'scrollToTop', - 'contentSizeChange', - ], - (handlerProps) => ( - - - - ), - ), - { - name: 'scroll from TextInput to ancestor ScrollView', - eventType: 'scroll', - ui: (handler) => ( - - - - ), - }, - { - name: 'refresh from FlatList item', - eventType: 'refresh', - ui: (handler) => ( - {item}} - refreshing={false} - onRefresh={handler} - /> - ), - }, - { - name: 'contentSizeChange from FlatList item', - eventType: 'contentSizeChange', - ui: (handler) => ( - {item}} - onContentSizeChange={handler} - /> - ), - }, - ...buildDirectEventCases( - 'Modal content', - ['requestClose', 'show', 'dismiss', 'orientationChange'], - (handlerProps) => ( - - Content - - ), - ), - ]; - - // Builds one case per event type, passing the matching `on*` handler prop to `ui`. - function buildDirectEventCases( - source: string, - eventTypes: string[], - ui: (handlerProps: Record) => React.ReactElement, - ): DirectEventCase[] { - return eventTypes.map((eventType) => ({ - name: `${eventType} from ${source}`, - eventType, - ui: (handler) => ui({ [getEventHandlerName(eventType)]: handler }), - })); - } - - test.each(directEventCases)('bubbles $name with a warning', async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('target'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test.each(directEventCases)( - 'does not warn for $name when fired on the emitting element', - async ({ eventType, ui }) => { - const handler = jest.fn(); - await render(ui(handler)); - - await fireEvent(screen.getByTestId('emitter'), eventType); - - expect(handler).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }, - ); - - test('warns when fired with "on" prefixed event name', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); - - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - test('warns about stopping bubbling in the next major version', async () => { - await render( - {}}> - - , - ); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " â–² fireEvent: "scroll" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. Fire it on: - - - " - `); - }); - - test.each([true, false])( - 'warns about bubbling regardless of eventDiagnostics (%s)', - async (eventDiagnostics) => { - configure({ eventDiagnostics }); - await render( - {}}> - - , - ); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); - }, - ); - - test('warns when handler is on composite component above the emitting element', async () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - ); - await render(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - // Known gap: only the type of the element with the handler is checked. - test('does not warn when handler is on an element that does not emit the event', async () => { - const onScroll = jest.fn(); - const Screen = (_props: { onScroll: () => void }) => ( - - - - - - ); - await render(); - - await fireEvent.scroll(screen.getByTestId('child')); - - expect(onScroll).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling to composite component handler', async () => { - const onLoad = jest.fn(); - const onShow = jest.fn(); - const Card = (_props: { onLoad: () => void; onShow: () => void }) => ( - - Card - - ); - await render(); - - await fireEvent(screen.getByText('Card'), 'load'); - await fireEvent(screen.getByText('Card'), 'show'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(onShow).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling to host element that does not emit the event', async () => { - const onLoad = jest.fn(); - await render( - // @ts-expect-error View does not have onLoad prop - - Content - , - ); - - await fireEvent(screen.getByText('Content'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when bubbling load event to ImageBackground handler', async () => { - const onLoad = jest.fn(); - await render( - - Caption - , - ); - - await fireEvent(screen.getByText('Caption'), 'load'); - - expect(onLoad).toHaveBeenCalledTimes(1); - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { - const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( - onCustomEvent('event data')}> - Custom - - ); - const handler = jest.fn(); - await render(); - await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'); - expect(handler).toHaveBeenCalledWith('event data'); -}); - -test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => { - const MyButton = ({ handlePress }: { handlePress: () => void }) => ( - - Button - - ); - const handler = jest.fn(); - await render(); - await fireEvent(screen.getByText('Button'), 'handlePress'); - expect(handler).toHaveBeenCalled(); -}); - -test('fireEvent returns undefined when handler does not return a value', async () => { - const handler = jest.fn(); - await render(); - const result = await fireEvent.press(screen.getByTestId('btn')); - expect(result).toBeUndefined(); -}); - -test('fireEvent calls handler on element when both element and parent have handlers', async () => { - const childHandler = jest.fn(); - const parentHandler = jest.fn(); - await render( - - - Press me - - , - ); - await fireEvent.press(screen.getByTestId('child')); - expect(childHandler).toHaveBeenCalledTimes(1); - expect(parentHandler).not.toHaveBeenCalled(); -}); - -test('fireEvent does nothing when element is unmounted', async () => { - const onPress = jest.fn(); - await render( - - - , - ); - const element = screen.getByTestId('btn'); - - await screen.rerender(); - await fireEvent.press(element); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('fireEvent does not throw when called with non-existent event name', async () => { - await render(); - const element = screen.getByTestId('btn'); - await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); -}); - -test('fireEvent handles handler that throws gracefully', async () => { - const error = new Error('Handler error'); - const onPress = jest.fn(() => { - throw error; - }); - await render(); - await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); - expect(onPress).toHaveBeenCalledTimes(1); -}); - -describe('disabled elements', () => { - test('does not fire on disabled Pressable', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('does not fire on disabled TouchableOpacity', async () => { - const onPress = jest.fn(); - await render( - - Trigger - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('bubbles event past disabled inner to enabled outer Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); - }); - - test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { - const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( - - Trigger Test - - ); - const handlePress = jest.fn(); - await render(); - await fireEvent.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); - }); -}); - -describe('unhandled event warning', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - configure({ eventDiagnostics: true }); - warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - test('warns when the handler is on a disabled element', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on a disabled element. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - " - `); - }); - - test('lists every disabled element the event skipped', async () => { - await render( - - - Trigger - - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on disabled elements. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Trigger - - - - - - - Trigger - - - " - `); - }); - - test('warns when no element handles the event', async () => { - await render( - - Trigger - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "No "onPress" handler found on the element or its ancestors. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - Trigger - " - `); - }); - - test('does not warn when the event bubbles to an enabled parent', async () => { - await render( - - - Inner Trigger - - , - ); - - await fireEvent.press(screen.getByText('Inner Trigger')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('warns when multiple handlers are blocked by pointerEvents="none" on an ancestor', async () => { - await render( - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inner')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on elements blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - - - - - Blocked by: - - " - `); - }); - - test('reports pointerEvents rather than disabled when both block the handler', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('btn')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports the element that blocks with pointerEvents', async () => { - await render( - - - - - - , - ); - - await fireEvent.press(screen.getByTestId('inside-box-only')); - await fireEvent.press(screen.getByTestId('box-none')); - - expect(warnSpy).toHaveBeenCalledTimes(2); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - expect(warnSpy.mock.calls[1][0]).toMatchInlineSnapshot(` - "Cannot fire the "press" event on an element blocked by pointerEvents. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - - - Blocked by: - - " - `); - }); - - test('reports non-editable TextInput for events not affected by pointerEvents', async () => { - await render( - - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - /^Cannot fire the "changeText" event on a non-editable TextInput\./, - ); - }); - - test('does not warn when the responder declines the touch', async () => { - const onPress = jest.fn(); - // @ts-expect-error Host View does not declare `onPress`. - await render( false} onPress={onPress} />); - - await fireEvent.press(screen.getByTestId('view')); - - expect(onPress).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('warns when the handler is blocked by non-editable TextInput', async () => { - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "changeText" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test.each([ - ['focus', 'onFocus'], - ['blur', 'onBlur'], - ['press', 'onPress'], - ])('warns when "%s" is blocked by non-editable TextInput', async (eventType, handlerName) => { - await render(); - - await fireEvent(screen.getByTestId('input'), eventType); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - new RegExp(`^Cannot fire the "${eventType}" event on a non-editable TextInput\\.`), - ); - }); - - test('names the non-editable TextInput when the handler is on its parent', async () => { - const onFocus = jest.fn(); - await render( - // Spread because `View` types include `onFocus` only since RN 0.88. - - - , - ); - - await fireEvent(screen.getByTestId('input'), 'focus'); - - expect(onFocus).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - "Cannot fire the "focus" event on a non-editable TextInput. - If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. - - " - `); - }); - - test('reports disabled elements when they include a non-editable TextInput', async () => { - await render( - - - , - ); - - await fireEvent.press(screen.getByTestId('input')); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toMatch( - /^Cannot fire the "press" event on disabled elements\./, - ); - }); - - test('does not warn when the event updates native state (uncontrolled TextInput)', async () => { - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test('does not warn when eventDiagnostics is turned off', async () => { - configure({ eventDiagnostics: false }); - await render( - - - Disabled - - No handler - , - ); - - await fireEvent.press(screen.getByText('Disabled')); - await fireEvent.press(screen.getByText('No handler')); - await fireEvent.layout(screen.getByText('No handler')); - - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); - -describe('pointerEvents prop', () => { - test('does not fire inside View with pointerEvents="none"', async () => { - const onPress = jest.fn(); - await render( - - - , - ); - await fireEvent.press(screen.getByTestId('btn')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('does not fire inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('fires inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - test('does not fire deeply inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - await render( - - - - Trigger - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(onPress).not.toHaveBeenCalled(); - }); - - test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); - - test('fires on Pressable with pointerEvents="box-only" on itself', async () => { - const onPress = jest.fn(); - await render(); - await fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); - }); -}); - -describe('non-editable TextInput', () => { - function WrappedTextInput(props: TextInputProps) { - return ; - } - - function DoubleWrappedTextInput(props: TextInputProps) { - return ; - } - - test('blocks touch-related events but allows non-touch events', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('blocks touch-related events when firing on nested Text child', async () => { - const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - - Nested Text - , - ); - - const subject = screen.getByText('Nested Text'); - 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(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, but still bubbles to the parent TextInput with a warning - expect(onLayout).toHaveBeenCalledTimes(2); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - expect(warnSpy).toHaveBeenCalledTimes(2); - warnSpy.mockRestore(); - }); - - test.each([ - ['WrappedTextInput', WrappedTextInput], - ['DoubleWrappedTextInput', DoubleWrappedTextInput], - ])('blocks touch-related events on %s', async (_, Component) => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const input = screen.getByTestId('input'); - await fireEvent(input, 'focus'); - await fireEvent.changeText(input, 'Text'); - await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(input, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); - }); - - test('fires layout event', async () => { - const onLayout = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'layout'); - expect(onLayout).toHaveBeenCalled(); - }); - - test('fires scroll event', async () => { - const onScroll = jest.fn(); - await render(); - await fireEvent(screen.getByTestId('input'), 'scroll'); - expect(onScroll).toHaveBeenCalled(); - }); -}); - -describe('responder system', () => { - test('respects disabled prop through composite wrappers', async () => { - function TestChildTouchableComponent({ - onPress, - someProp, - }: { - onPress: () => void; - someProp: boolean; - }) { - return ( - - - Trigger - - - ); - } - const handlePress = jest.fn(); - await render( - - - , - ); - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); - }); - - test('fires responderMove on PanResponder component', async () => { - const onDrag = jest.fn(); - function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: () => true, - onPanResponderMove: onDrag, - }).panHandlers; - return ( - - Trigger - - ); - } - await render(); - await fireEvent(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, - }); - expect(onDrag).toHaveBeenCalled(); - }); -}); diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 94a970247..688d02cc0 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -1,6 +1,4 @@ export * from './builders/common'; export * from '../shared/handler'; -export { isPointerEventEnabled } from './is-enabled'; +export { isPointerEventEnabled } from '../shared/pointer-events'; export * from '../shared/native-state'; -export type { EventWarning } from './warnings'; -export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/legacy/is-enabled.ts b/src/events/legacy/is-enabled.ts index 67429aa50..87d30dffe 100644 --- a/src/events/legacy/is-enabled.ts +++ b/src/events/legacy/is-enabled.ts @@ -1,27 +1,8 @@ -import { StyleSheet } from 'react-native'; import type { TestInstance } from 'test-renderer'; import { isHostTextInput } from '../../helpers/host-component-names'; import { isEditableTextInput } from '../../helpers/text-input'; - -/** - * pointerEvents controls whether the View can be the target of touch events. - * 'auto': The View and its children can be the target of touch events. - * 'none': The View is never the target of touch events. - * 'box-none': The View is never the target of touch events but its subviews can be - * 'box-only': The view can be the target of touch events but its subviews cannot be - * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export function isPointerEventEnabled(instance: TestInstance): boolean { - return getPointerEventsBlocker(instance) == null; -} - -/** - * Returns the element whose `pointerEvents` prevents the instance from being the target of - * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. - */ -export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { - return findPointerEventsBlocker(instance, false); -} +import { isPointerEventEnabled } from '../shared/pointer-events'; export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -73,21 +54,3 @@ export function isEventEnabled( return touchStart === undefined && touchMove === undefined; } - -function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { - // Check both props.pointerEvents and props.style.pointerEvents - const pointerEvents = - instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; - - const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; - - if (pointerEvents === 'none' || parentCondition) { - return instance; - } - - if (!instance.parent) { - return null; - } - - return findPointerEventsBlocker(instance.parent, true); -} diff --git a/src/events/legacy/warnings.ts b/src/events/legacy/warnings.ts index 5a81beade..c0600bcab 100644 --- a/src/events/legacy/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -3,25 +3,22 @@ import type { TestInstance } from 'test-renderer'; import { getConfig } from '../../config'; import { computeAriaDisabled } from '../../helpers/accessibility'; -import { formatElement, formatJson } from '../../helpers/format-element'; -import { isHostTextInput } from '../../helpers/host-component-names'; +import { formatElement } from '../../helpers/format-element'; import { logger } from '../../helpers/logger'; -import { isEditableTextInput } from '../../helpers/text-input'; import { getEventHandlerName, normalizeEventType } from '../shared/handler'; -import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; +import type { EventWarning } from '../shared/warnings'; +import { + formatDisabledTargets, + getPointerEventsBlockedTargets, + logEventWarning, +} from '../shared/warnings'; +import { isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { skippedTargets: TestInstance[]; hasUpdatedNativeState: boolean; }; -export type EventWarning = { - message: string; - elements: TestInstance[]; - /** Elements whose `pointerEvents` blocked the event. */ - pointerEventsBlockers?: TestInstance[]; -}; - /** * Warns when no handler ran because the target is disabled or nothing handles the event. */ @@ -51,56 +48,6 @@ export function warnAboutBubblingDirectEvent(eventType: string, owner: TestInsta ); } -export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { - const header = - `${message}\n` + - 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; - const elementBlocks = elements - .map((element) => element.toJSON()) - .filter((json) => json != null) - .map((json) => redent(formatJson(json), 2)); - // Blockers are often large containers, so they are printed without their children. - const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => - redent(formatElement(blocker), 2), - ); - const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; - logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); -} - -/** - * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it - * blocks none of them. - * - * @param targets Skipped elements, nearest first. - */ -export function getPointerEventsBlockedTargets( - targets: TestInstance[], -): Pick | null { - const elements: TestInstance[] = []; - const pointerEventsBlockers: TestInstance[] = []; - for (const target of targets) { - const blocker = getPointerEventsBlocker(target); - if (blocker != null) { - elements.push(target); - pointerEventsBlockers.push(blocker); - } - } - - return elements.length > 0 ? { elements, pointerEventsBlockers } : null; -} - -/** - * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element". - * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`). - */ -export function formatDisabledTargets(targets: TestInstance[]): string { - if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) { - return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs'; - } - - return targets.length === 1 ? 'a disabled element' : 'disabled elements'; -} - function getUnhandledEventWarning( instance: TestInstance, eventType: string, diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx deleted file mode 100644 index 0970d6093..000000000 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ /dev/null @@ -1,806 +0,0 @@ -import * as React from 'react'; -import { ScrollView, Text, TextInput, View } from 'react-native'; -import type { TestInstance } from 'test-renderer'; - -import { render, screen } from '../../..'; -import { nativeState } from '../../shared/native-state'; -import type { SyntheticEvent } from '../event'; -import type { FireEventInit } from '../fire-event'; -import { fireEvent } from '../fire-event'; - -const defaultScrollPayload = { - contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, - contentOffset: { x: 0, y: 0 }, - contentSize: { height: 0, width: 0 }, - layoutMeasurement: { height: 0, width: 0 }, - responderIgnoreScroll: true, - target: 0, - velocity: { x: 0, y: 0 }, -}; - -/** Calls `fireEvent` with arguments its types don't allow, as JavaScript callers can. */ -function fireEventUntyped(instance: TestInstance, eventType: string, ...args: unknown[]) { - return fireEvent(instance, eventType, ...(args as [FireEventInit])); -} - -describe('event object', () => { - test('throws without an event object', async () => { - const onFocus = jest.fn(); - await render(); - - await expect(fireEventUntyped(screen.getByTestId('input'), 'focus')).rejects.toThrow( - `Unable to fire a "focus" event. Please provide an event object, e.g. fireEvent(element, 'focus', { nativeEvent: {} }).`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test('throws with more than one argument after the event type', async () => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {} }, {}), - ).rejects.toThrow( - 'Unable to fire a "focus" event. Expected a single event object, received 2 arguments.', - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test.each([ - ['text', 'string "text"'], - [42, 'number 42'], - [null, 'null'], - [undefined, 'undefined'], - [[{ nativeEvent: {} }], 'an array'], - [new Date(0), 'an instance of Date'], - [() => {}, 'a function'], - ])('throws when the event is %p', async (event, description) => { - const onFocus = jest.fn(); - await render(); - - await expect(fireEventUntyped(screen.getByTestId('input'), 'focus', event)).rejects.toThrow( - `Unable to fire a "focus" event. Expected an event object, received ${description}.`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test.each([ - [{ target: 1 }, '"target". "target" is set by the dispatch.'], - [ - { target: {}, currentTarget: { measure: () => {} } }, - '"target", "currentTarget". "target", "currentTarget" are set by the dispatch.', - ], - [{ persist: () => {} }, '"persist". "persist" is provided by the event.'], - [ - { preventDefault: () => {}, dispatchConfig: {} }, - '"preventDefault", "dispatchConfig". "preventDefault", "dispatchConfig" are provided by the event.', - ], - [{ text: 'Hello' }, '"text". Pass "text" in "nativeEvent" instead.'], - [ - { text: 'Hello', target: 1, persist: () => {} }, - '"text", "target", "persist". "target" is set by the dispatch. "persist" is provided by the event. Pass "text" in "nativeEvent" instead.', - ], - ])('throws on unsupported keys %p', async (event, message) => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: {}, ...event }), - ).rejects.toThrow(`Unable to fire a "focus" event. Unsupported event object keys: ${message}`); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test('throws when nativeEvent is not an object', async () => { - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { nativeEvent: 'text' }), - ).rejects.toThrow( - 'Unable to fire a "focus" event. Expected "nativeEvent" to be an object, received string "text".', - ); - }); - - test.each([ - ['123', 'string "123"'], - [Number.NaN, 'number NaN'], - [Infinity, 'number Infinity'], - [null, 'null'], - ])('throws when timeStamp is %p', async (timeStamp, description) => { - const onFocus = jest.fn(); - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'focus', { timeStamp }), - ).rejects.toThrow( - `Unable to fire a "focus" event. Expected "timeStamp" to be a finite number, received ${description}.`, - ); - expect(onFocus).not.toHaveBeenCalled(); - }); - - test('passes timeStamp to handlers as event.timeStamp', async () => { - const onFocus = jest.fn(); - await render(); - - const nativeEvent = { timestamp: 100 }; - await fireEvent(screen.getByTestId('input'), 'focus', { nativeEvent, timeStamp: 42 }); - - const event: SyntheticEvent = onFocus.mock.calls[0][0]; - expect(event.timeStamp).toBe(42); - expect(event.nativeEvent).toBe(nativeEvent); - expect(nativeEvent).toEqual({ timestamp: 100 }); - }); - - test('passes nativeEvent to handlers as event.nativeEvent', async () => { - const onSubmitEditing = jest.fn(); - await render(); - - const nativeEvent = { text: 'Hello', target: 1 }; - await fireEvent(screen.getByTestId('input'), 'submitEditing', { nativeEvent }); - - expect(onSubmitEditing).toHaveBeenCalledTimes(1); - const event: SyntheticEvent = onSubmitEditing.mock.calls[0][0]; - expect(event.type).toBe('submitediting'); - expect(event.nativeEvent).toBe(nativeEvent); - expect(event.target).toBe(screen.getByTestId('input')); - }); - - test('uses an empty nativeEvent when the event object has none', async () => { - const onFocus = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - - expect(onFocus.mock.calls[0][0].nativeEvent).toEqual({}); - }); - - test('accepts an event object without a prototype', async () => { - const onFocus = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('input'), 'focus', Object.create(null)); - - expect(onFocus).toHaveBeenCalledTimes(1); - }); -}); - -test('throws without an element', async () => { - await expect( - fireEventUntyped(null as unknown as TestInstance, 'focus', { nativeEvent: {} }), - ).rejects.toThrow('Unable to fire a "focus" event. Please provide a host element.'); -}); - -test('accepts event type with or without the "on" prefix', async () => { - const onFocus = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - await fireEvent(screen.getByTestId('input'), 'onFocus', {}); - - expect(onFocus).toHaveBeenCalledTimes(2); - expect(onFocus.mock.calls[1][0].type).toBe('focus'); -}); - -test('dispatches bubbling events through capture and bubble phases', async () => { - const calls: string[] = []; - await render( - calls.push('parent.onPointerDownCapture')} - onPointerDown={() => calls.push('parent.onPointerDown')} - > - calls.push('target.onPointerDown')} /> - , - ); - - await fireEvent(screen.getByTestId('target'), 'pointerDown', {}); - - expect(calls).toEqual([ - 'parent.onPointerDownCapture', - 'target.onPointerDown', - 'parent.onPointerDown', - ]); -}); - -test('dispatches direct events to the target only', async () => { - const onParentLayout = jest.fn(); - const onLayout = jest.fn(); - await render( - - - , - ); - - const nativeEvent = { layout: { x: 0, y: 0, width: 100, height: 50 } }; - await fireEvent(screen.getByTestId('target'), 'layout', { nativeEvent }); - - expect(onLayout).toHaveBeenCalledTimes(1); - expect(onLayout.mock.calls[0][0].nativeEvent).toBe(nativeEvent); - expect(onParentLayout).not.toHaveBeenCalled(); -}); - -test('does not call props of composite components', async () => { - const onCompositeFocus = jest.fn(); - function Wrapper({ children }: { children: React.ReactNode; onFocus: () => void }) { - return {children}; - } - - await render( - - - , - ); - - await fireEvent(screen.getByTestId('input'), 'focus', {}); - - expect(onCompositeFocus).not.toHaveBeenCalled(); -}); - -describe('event types unknown to React Native', () => { - test.each([ - ['changeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], - ['onChangeText', 'changeText', '. Use fireEvent.changeText() or userEvent.type() instead.'], - ['pressIn', 'pressIn', '. Use userEvent.press() instead.'], - ['pressOut', 'pressOut', '. Use userEvent.press() instead.'], - ['longPress', 'longPress', '. Use userEvent.longPress() instead.'], - ['customEvent', 'customEvent', ', so no handler would be called.'], - ])('"%s" throws', async (eventType, normalizedType, hint) => { - const handler = jest.fn(); - const handlerProps = { - onChangeText: handler, - onPressIn: handler, - onPressOut: handler, - onLongPress: handler, - onCustomEvent: handler, - }; - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), eventType, { nativeEvent: {} }), - ).rejects.toThrow( - `Unable to fire a "${normalizedType}" event. React Native doesn't dispatch ` + - `"${normalizedType}" natively${hint}`, - ); - expect(handler).not.toHaveBeenCalled(); - }); - - test('throws before checking the event object', async () => { - await render(); - - await expect( - fireEventUntyped(screen.getByTestId('input'), 'changeText', 'Hello'), - ).rejects.toThrow(`React Native doesn't dispatch "changeText" natively`); - }); -}); - -test('returns false when a handler calls preventDefault()', async () => { - await render( - event.preventDefault()}> - Press me - , - ); - - expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(false); -}); - -test('returns true when no handler calls preventDefault()', async () => { - const onPress = jest.fn(); - await render( - - Press me - , - ); - - expect(await fireEvent(screen.getByTestId('text'), 'press', {})).toBe(true); - expect(onPress).toHaveBeenCalledTimes(1); -}); - -test('is a no-op on an unmounted element', async () => { - const onFocus = jest.fn(); - await render(); - const input = screen.getByTestId('input'); - await screen.rerender(); - - expect(await fireEvent(input, 'focus', {})).toBe(true); - expect(onFocus).not.toHaveBeenCalled(); -}); - -test('rejects with the handler error', async () => { - await render( - { - throw new Error('Handler error'); - }} - />, - ); - - await expect(fireEvent(screen.getByTestId('input'), 'focus', {})).rejects.toThrow( - 'Handler error', - ); -}); - -test('renders state updates from the handler', async () => { - function Counter() { - const [count, setCount] = React.useState(0); - return ( - setCount((value) => value + 1)}> - Count: {count} - - ); - } - - await render(); - await fireEvent(screen.getByTestId('text'), 'press', {}); - - expect(screen.getByText('Count: 1')).toBeOnTheScreen(); -}); - -describe('native state', () => { - test.each([ - 'scroll', - 'onScroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', - ])('saves content offset of ScrollView from %s event', async (eventType) => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent(scrollView, eventType, { nativeEvent: { contentOffset: { y: 200 } } }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 200 }); - }); - - test('does not save content offset from scroll event without one', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); - - test('saves layout size from layout event, with non-finite values as 0', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent(view, 'layout', { nativeEvent: { layout: { width: 50, height: NaN } } }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 50, height: 0 }); - }); - - test.each(['change', 'onChange'])('saves value of TextInput from %s event', async (eventType) => { - await render(); - const input = screen.getByTestId('input'); - - await fireEvent(input, eventType, { nativeEvent: { text: 'Hello' } }); - - expect(nativeState.valueForInstance.get(input)).toBe('Hello'); - }); - - test('does not save value from change event without text or on non-editable TextInput', async () => { - await render( - <> - - - , - ); - const input = screen.getByTestId('input'); - const nonEditable = screen.getByTestId('non-editable'); - - await fireEvent(input, 'change', { nativeEvent: {} }); - await fireEvent(nonEditable, 'change', { nativeEvent: { text: 'Hello' } }); - - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - expect(nativeState.valueForInstance.get(nonEditable)).toBeUndefined(); - }); - - test('is saved before handlers run', async () => { - let contentOffset; - const onScroll = jest.fn(() => { - contentOffset = nativeState.contentOffsetForInstance.get(screen.getByTestId('scroll')); - }); - await render(); - - await fireEvent(screen.getByTestId('scroll'), 'scroll', { - nativeEvent: { contentOffset: { y: 200 } }, - }); - - expect(contentOffset).toEqual({ x: 0, y: 200 }); - }); - - test('is not saved for unmounted element', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - await screen.rerender(); - - await fireEvent(scrollView, 'scroll', { nativeEvent: { contentOffset: { y: 200 } } }); - - expect(nativeState.contentOffsetForInstance.get(scrollView)).toBeUndefined(); - }); -}); - -describe('fireEvent.changeText', () => { - function logChangeHandlers(calls: string[], id: string) { - return { - onChange: () => calls.push(`${id}.onChange`), - onChangeCapture: () => calls.push(`${id}.onChangeCapture`), - }; - } - - test('fires change event with the text, then calls onChangeText', async () => { - const calls: string[] = []; - const onChange = jest.fn((_event: unknown) => calls.push('onChange')); - const onChangeText = jest.fn((_text: string) => calls.push('onChangeText')); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(calls).toEqual(['onChange', 'onChangeText']); - expect(onChangeText).toHaveBeenCalledWith('Hello'); - const event = onChange.mock.calls[0][0] as SyntheticEvent; - expect(event.type).toBe('change'); - expect(event.target).toBe(screen.getByTestId('input')); - expect(event.nativeEvent).toEqual({ - text: 'Hello', - target: 0, - eventCount: 0, - selection: { start: 5, end: 5 }, - }); - }); - - test('calls onChangeText after the input onChange, before ancestors onChange', async () => { - const calls: string[] = []; - await render( - - calls.push('input.onChangeText')} - /> - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(calls).toEqual([ - 'parent.onChangeCapture', - 'input.onChangeCapture', - 'input.onChange', - 'input.onChangeText', - 'parent.onChange', - ]); - }); - - test('does not call onChangeText of an ancestor', async () => { - const onChangeText = jest.fn(); - await render( - - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('does not call onChange or onChangeText when a capture handler stops propagation', async () => { - const onChange = jest.fn(); - const onChangeText = jest.fn(); - await render( - event.stopPropagation() }}> - - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChange).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('calls onChangeText when the input onChange stops propagation', async () => { - const onParentChange = jest.fn(); - const onChangeText = jest.fn(); - await render( - - event.stopPropagation()} - onChangeText={onChangeText} - /> - , - ); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChangeText).toHaveBeenCalledWith('Hello'); - expect(onParentChange).not.toHaveBeenCalled(); - }); - - test('does not call onChangeText when the input onChange throws', async () => { - const onChangeText = jest.fn(); - await render( - { - throw new Error('Change error'); - }} - onChangeText={onChangeText} - />, - ); - - await expect(fireEvent.changeText(screen.getByTestId('input'), 'Hello')).rejects.toThrow( - 'Change error', - ); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('calls onChangeText without onChange', async () => { - const onChangeText = jest.fn(); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(onChangeText).toHaveBeenCalledWith('Hello'); - }); - - test('saves value of uncontrolled TextInput before handlers run', async () => { - let value; - const onChangeText = jest.fn(() => { - value = nativeState.valueForInstance.get(screen.getByTestId('input')); - }); - await render(); - - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(value).toBe('Hello'); - expect(screen.getByTestId('input')).toHaveDisplayValue('Hello'); - }); - - test('renders state updates from handlers', async () => { - function Subject() { - const [value, setValue] = React.useState(''); - return ; - } - - await render(); - await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); - - expect(screen.getByTestId('input')).toHaveDisplayValue('Hello'); - }); - - test('returns false when a handler calls preventDefault()', async () => { - await render( event.preventDefault()} />); - - expect(await fireEvent.changeText(screen.getByTestId('input'), 'Hello')).toBe(false); - }); - - test('does nothing on non-editable TextInput', async () => { - const onChange = jest.fn(); - const onChangeText = jest.fn(); - await render( - , - ); - const input = screen.getByTestId('input'); - - expect(await fireEvent.changeText(input, 'Hello')).toBe(true); - - expect(onChange).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(nativeState.valueForInstance.get(input)).toBeUndefined(); - }); - - test('throws on elements other than TextInput', async () => { - const onChangeText = jest.fn(); - await render(); - - await expect(fireEvent.changeText(screen.getByTestId('view'), 'Hello')).rejects.toThrow( - 'Unable to fire a "changeText" event. Expected a host "TextInput" element, received "View".', - ); - expect(onChangeText).not.toHaveBeenCalled(); - }); - - test('throws when text is not a string', async () => { - await render(); - - await expect( - fireEvent.changeText(screen.getByTestId('input'), 5 as unknown as string), - ).rejects.toThrow( - 'Unable to fire a "changeText" event. Expected text to be a string, received number 5.', - ); - }); - - test('throws without an element', async () => { - await expect(fireEvent.changeText(null as unknown as TestInstance, 'Hello')).rejects.toThrow( - 'Unable to fire a "changeText" event. Please provide a host element.', - ); - }); -}); - -describe('fireEvent.scroll', () => { - test('passes default scroll payload as nativeEvent of a direct event', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll')); - - const event: SyntheticEvent = onScroll.mock.calls[0][0]; - expect(event.type).toBe('scroll'); - expect(event.rnIsDirect).toBe(true); - expect(event.nativeEvent).toEqual(defaultScrollPayload); - }); - - test('deep merges passed nativeEvent onto default payload', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), { - nativeEvent: { contentOffset: { y: 200 }, zoomScale: 2 }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent).toEqual({ - ...defaultScrollPayload, - contentOffset: { x: 0, y: 200 }, - zoomScale: 2, - }); - }); - - test('passes timeStamp to handlers', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), { timeStamp: 123 }); - - expect(onScroll.mock.calls[0][0].timeStamp).toBe(123); - }); - - test('saves content offset of ScrollView in native state', async () => { - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { x: 50 } } }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 50, y: 0 }); - - await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: Infinity } } }); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 0 }); - }); - - test('uses layout size of ScrollView as default layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render(); - const scrollView = screen.getByTestId('scroll'); - - await fireEvent.layout(scrollView, { width: 390, height: 750 }); - await fireEvent.scroll(scrollView); - await fireEvent.scroll(scrollView, { - nativeEvent: { layoutMeasurement: { width: 100, height: 200 } }, - }); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 390, - height: 750, - }); - expect(onScroll.mock.calls[1][0].nativeEvent.layoutMeasurement).toEqual({ - width: 100, - height: 200, - }); - }); - - test('does not use layout size of other elements as layoutMeasurement', async () => { - const onScroll = jest.fn(); - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { width: 390, height: 750 }); - await fireEvent.scroll(view); - - expect(onScroll.mock.calls[0][0].nativeEvent.layoutMeasurement).toEqual({ - width: 0, - height: 0, - }); - }); - - test('does not call onScroll of an ancestor ScrollView', async () => { - const onScroll = jest.fn(); - await render( - - - , - ); - - await fireEvent.scroll(screen.getByTestId('content')); - - expect(onScroll).not.toHaveBeenCalled(); - }); - - test('uses default payload when the event object is undefined', async () => { - const onScroll = jest.fn(); - await render(); - - await fireEvent.scroll(screen.getByTestId('scroll'), undefined); - - expect(onScroll.mock.calls[0][0].nativeEvent).toEqual(defaultScrollPayload); - }); - - test('validates the event object', async () => { - await render(); - - await expect( - fireEvent.scroll(screen.getByTestId('scroll'), { persist: () => {} } as FireEventInit), - ).rejects.toThrow('Unable to fire a "scroll" event. Unsupported event object keys: "persist".'); - }); - - test('throws without an element', async () => { - await expect(fireEvent.scroll(null as unknown as TestInstance)).rejects.toThrow( - 'Unable to fire a "scroll" event. Please provide a host element.', - ); - }); -}); - -describe('fireEvent.layout', () => { - test('passes zeroed layout as nativeEvent of a direct event', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - const event: SyntheticEvent = onLayout.mock.calls[0][0]; - expect(event.type).toBe('layout'); - expect(event.rnIsDirect).toBe(true); - expect(event.nativeEvent).toEqual({ layout: { x: 0, y: 0, width: 0, height: 0 }, target: 0 }); - }); - - test('merges passed layout onto zeroed rectangle, with undefined fields as 0', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent.layout(screen.getByTestId('view'), { width: 200, height: 80, x: undefined }); - - expect(onLayout.mock.calls[0][0].nativeEvent.layout).toEqual({ - x: 0, - y: 0, - width: 200, - height: 80, - }); - }); - - test('saves layout size in native state, also without onLayout handler', async () => { - await render(); - const view = screen.getByTestId('view'); - - await fireEvent.layout(view, { x: 10, y: 20, width: 100, height: 80 }); - - expect(nativeState.layoutSizeForInstance.get(view)).toEqual({ width: 100, height: 80 }); - }); - - test('does not call onLayout of an ancestor', async () => { - const onLayout = jest.fn(); - await render( - - - , - ); - - await fireEvent.layout(screen.getByTestId('child'), { height: 80 }); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('does not call onLayout of composite component that does not forward it', async () => { - const onLayout = jest.fn(); - const Box = (_props: { onLayout: () => void }) => ; - await render(); - - await fireEvent.layout(screen.getByTestId('view')); - - expect(onLayout).not.toHaveBeenCalled(); - }); - - test('throws without an element', async () => { - await expect(fireEvent.layout(null as unknown as TestInstance)).rejects.toThrow( - 'Unable to fire a "layout" event. Please provide a host element.', - ); - }); -}); diff --git a/src/events/modern/__tests__/pressability.test.tsx b/src/events/modern/__tests__/pressability.test.tsx new file mode 100644 index 000000000..3c41d7348 --- /dev/null +++ b/src/events/modern/__tests__/pressability.test.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Pressable, Text, TouchableOpacity, View } from 'react-native'; + +import { render, screen } from '../../..'; +import { getEventHandlerName } from '../../shared/handler'; +import type { SyntheticEvent } from '../event'; +import { createEvent } from '../event'; +import { PRESSABILITY_EVENT_TYPES } from '../event-types'; +import { dispatchPressabilityEvent } from '../pressability'; + +function createTouchEvent() { + return createEvent('touchEnd', { nativeEvent: { pageX: 10 } }) as SyntheticEvent; +} + +function logCallbacks(calls: string[], id: string) { + const log = (name: string) => () => { + calls.push(`${id}.${name}`); + }; + return { + onPress: log('onPress'), + onPressIn: log('onPressIn'), + onPressOut: log('onPressOut'), + onLongPress: log('onLongPress'), + }; +} + +test.each(PRESSABILITY_EVENT_TYPES)( + '%s calls only Pressable and TouchableOpacity callback', + async (eventType) => { + const name = getEventHandlerName(eventType); + const calls: string[] = []; + await render( + <> + + Pressable + + + Touchable + + , + ); + + await dispatchPressabilityEvent(screen.getByText('Pressable'), eventType, createTouchEvent()); + await dispatchPressabilityEvent(screen.getByText('Touchable'), eventType, createTouchEvent()); + + expect(calls).toEqual([`pressable.${name}`, `touchable.${name}`]); + }, +); + +test.each(PRESSABILITY_EVENT_TYPES)( + '%s calls only host callback, incl. testOnly_ props', + async (eventType) => { + const name = getEventHandlerName(eventType); + const calls: string[] = []; + await render( + <> + Text + calls.push(`view.testOnly_${name}`) }} + /> + , + ); + + await dispatchPressabilityEvent(screen.getByText('Text'), eventType, createTouchEvent()); + await dispatchPressabilityEvent(screen.getByTestId('view'), eventType, createTouchEvent()); + + expect(calls).toEqual([`text.${name}`, `view.testOnly_${name}`]); + }, +); + +test('passes the event, with the hit target and the responder', async () => { + const onPressIn = jest.fn((event) => ({ + currentTarget: event.currentTarget, + target: event.target, + })); + await render( + + Press me + , + ); + const event = createTouchEvent(); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', event); + + expect(onPressIn).toHaveBeenCalledWith(event); + expect(onPressIn.mock.results[0].value).toEqual({ + currentTarget: screen.getByTestId('pressable'), + target: screen.getByText('Press me'), + }); +}); + +test('host element with another press callback claims the touch, but gets nothing', async () => { + const onPressIn = jest.fn(); + await render( + + Press me + , + ); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'pressIn', createTouchEvent()); + + expect(onPressIn).not.toHaveBeenCalled(); +}); + +test('responder without the callback keeps the touch from its ancestors', async () => { + const onLongPress = jest.fn(); + await render( + + + Press me + + , + ); + + await dispatchPressabilityEvent(screen.getByText('Press me'), 'longPress', createTouchEvent()); + + expect(onLongPress).not.toHaveBeenCalled(); +}); + +test('does not call callbacks of disabled Pressable', async () => { + const calls: string[] = []; + await render( + + Press me + , + ); + + for (const eventType of PRESSABILITY_EVENT_TYPES) { + await dispatchPressabilityEvent(screen.getByText('Press me'), eventType, createTouchEvent()); + } + + expect(calls).toEqual([]); +}); diff --git a/src/events/modern/dispatch.ts b/src/events/modern/dispatch.ts index 290445c99..52df6f595 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -157,7 +157,7 @@ function callHandler( } /** Clears the event's internals, as the event can still be read after the dispatch. */ -function resetEvent(event: SyntheticEvent) { +export function resetEvent(event: SyntheticEvent) { getEventInternals(event).eventPhase = SyntheticEvent.NONE; getEventInternals(event).currentTarget = null; getEventInternals(event).composedPath = []; diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index 60dd241bb..f776a13c4 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -81,6 +81,20 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ 'textLayout', ]; +/** + * Pressability callbacks (`onPress`, `onPressIn`, ...) that modern `fireEvent` calls. They are not + * native events: Pressability calls them on the touch responder from responder events, so + * `dispatchPressabilityEvent()` calls them instead of `dispatchEvent()`. `press` is also a native + * event type, but `fireEvent` calls Pressability's `onPress` for it. + */ +export const PRESSABILITY_EVENT_TYPES = ['press', 'pressIn', 'pressOut', 'longPress'] as const; + +export type PressabilityEventType = (typeof PRESSABILITY_EVENT_TYPES)[number]; + +export function isPressabilityEventType(eventType: string): eventType is PressabilityEventType { + return (PRESSABILITY_EVENT_TYPES as readonly string[]).includes(eventType); +} + /** * Direct events whose handler prop doesn't follow the `on` + event type pattern. */ diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 30ee22fd4..1c24eb36b 100644 --- a/src/events/modern/event.ts +++ b/src/events/modern/event.ts @@ -1,6 +1,9 @@ import type { TestInstance } from 'test-renderer'; -import type { EventTypeConfig } from './event-types'; +import { getEventHandlerName } from '../shared/handler'; +import { mergeEventProps } from '../shared/merge'; +import { buildTouchNativeEvent } from '../shared/payloads'; +import type { EventTypeConfig, PressabilityEventType } from './event-types'; import { getEventTypeConfig } from './event-types'; export type NativeEventPayload = Record; @@ -223,6 +226,31 @@ export function createEvent(eventType: string, init: CreateEventInit = {}): Synt return null; } + return createEventWithConfig(eventType, init, config); +} + +/** + * Creates the event passed to a Pressability callback, ready for `dispatchPressabilityEvent()`. + * React Native passes the responder event derived from a touch, so the passed `nativeEvent` is + * deep merged onto a default touch payload. Pressability calls only the touch responder's + * callback, so the event is direct. + * + * @param eventType e.g. `press` or `pressIn` + */ +export function createPressabilityEvent( + eventType: PressabilityEventType, + init: CreateEventInit, +): SyntheticEvent { + const nativeEvent = mergeEventProps(buildTouchNativeEvent(), init.nativeEvent); + const config: EventTypeConfig = { kind: 'direct', handlerName: getEventHandlerName(eventType) }; + return createEventWithConfig(eventType, { timeStamp: init.timeStamp, nativeEvent }, config); +} + +function createEventWithConfig( + eventType: string, + init: CreateEventInit, + config: EventTypeConfig, +): SyntheticEvent { const nativeEvent = init.nativeEvent ?? {}; // React Native keeps the native timestamp as the event's `timeStamp`. const nativeTimeStamp = nativeEvent.timeStamp ?? nativeEvent.timestamp; diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 6da71db9c..0e06c4736 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -4,7 +4,7 @@ import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; -import { getEventTypeConfig } from './event-types'; +import { getEventTypeConfig, isPressabilityEventType } from './event-types'; const ALLOWED_INIT_KEYS = ['nativeEvent', 'timeStamp']; @@ -18,15 +18,13 @@ const EVENT_KEYS = new Set( /** What to use instead of event types that only the legacy system or `userEvent` can fire. */ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { changeText: 'fireEvent.changeText() or userEvent.type()', - pressIn: 'userEvent.press()', - pressOut: 'userEvent.press()', - longPress: 'userEvent.longPress()', }; /** * Throws for event types React Native doesn't dispatch natively, such as `changeText` or custom - * prop names. React Native drops them, so dropping them here would call no handler, and a test - * asserting that a handler wasn't called would pass for the wrong reason. + * prop names, except Pressability event types (`pressIn`, ...). React Native drops them, so + * dropping them here would call no handler, and a test asserting that a handler wasn't called + * would pass for the wrong reason. * * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ @@ -35,7 +33,7 @@ export function ensureEventType( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callsite: Function, ): void { - if (getEventTypeConfig(eventType) != null) { + if (getEventTypeConfig(eventType) != null || isPressabilityEventType(eventType)) { return; } diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 1a711c5df..b4c8a21ac 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -18,13 +18,21 @@ import { import type { DispatchOptions } from './dispatch'; import { dispatchEvent } from './dispatch'; import type { CreateEventInit, NativeEventPayload } from './event'; -import { createEvent } from './event'; +import { createEvent, createPressabilityEvent } from './event'; +import type { PressabilityEventType } from './event-types'; +import { isPressabilityEventType } from './event-types'; import { ensureEventType, ensureSingleEventArg, validateChangeTextArgs, validateEventInit, } from './fire-event-utils'; +import { dispatchPressabilityEvent } from './pressability'; +import { + warnAboutNonEditableTextInput, + warnAboutUnhandledEvent, + warnAboutUnhandledPressabilityEvent, +} from './warnings'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -36,24 +44,34 @@ export type FireEventInit = CreateEventInit; * Fires an event on a host element: `createEvent()`, then `dispatchEvent()`, like Testing Library's * DOM `fireEvent(element, event)`. The event type is needed to find the event's dispatch config. * - * Exactly one event object is required, as React Native handlers receive a single event. Event - * types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the + * element that becomes the touch responder instead, as `fireEvent.press()` does. + * + * Exactly one event object is required, as React Native handlers receive a single event. Other + * event types React Native doesn't dispatch natively (`changeText`, custom prop names) throw. + * + * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that + * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * * @param eventType with or without the `on*` prefix, e.g. `focus` or `onFocus` - * @returns `false` if a handler called `preventDefault()`, otherwise `true`. */ export async function fireEvent( instance: TestInstance, eventType: string, ...args: [event: FireEventInit] -): Promise { +): Promise { const normalizedType = normalizeEventType(eventType); ensureInstance(instance, normalizedType, fireEvent); // Before the event object, as `fireEvent(input, 'changeText', 'Hello')` needs the hint more. ensureEventType(normalizedType, fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); - return await fireEventInternal(instance, normalizedType, init); + if (isPressabilityEventType(normalizedType)) { + await firePressabilityEvent(instance, normalizedType, init); + return; + } + + await fireEventInternal(instance, normalizedType, init); } /** @@ -61,14 +79,15 @@ export async function fireEvent( * and calls `onChangeText(text)` right after the input's own `onChange`, as `TextInput` does from * its `onChange` wrapper. Ancestors receive the bubbling `onChange`, but never `onChangeText`. * A non-editable `TextInput` receives no events, as on a device. - * - * @returns `false` if a handler called `preventDefault()`, otherwise `true`. */ -fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { ensureInstance(instance, 'changeText', fireEvent.changeText); validateChangeTextArgs(instance, text, fireEvent.changeText); if (!isEditableTextInput(instance)) { - return true; + if (isInstanceMounted(instance)) { + warnAboutNonEditableTextInput(instance); + } + return; } const selection = { start: text.length, end: text.length }; @@ -79,26 +98,43 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { + ensureInstance(instance, 'press', fireEvent.press); + const init = validateEventInit('press', event, fireEvent.press); + await firePressabilityEvent(instance, 'press', init); }; /** * Fires a `scroll` event. The passed `nativeEvent` is deep merged onto a default scroll payload, * whose `layoutMeasurement` is the `ScrollView`'s size from its last `layout` event. */ -fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { +fireEvent.scroll = async (instance: TestInstance, event: FireEventInit = {}): Promise => { ensureInstance(instance, 'scroll', fireEvent.scroll); const init = validateEventInit('scroll', event, fireEvent.scroll); - return await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); + await fireEventInternal(instance, 'scroll', init, buildScrollNativeEvent()); }; /** Fires a `layout` event. The passed `layout` is merged onto a zeroed rectangle. */ fireEvent.layout = async ( instance: TestInstance, layout?: Partial, -): Promise => { +): Promise => { ensureInstance(instance, 'layout', fireEvent.layout); - return await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); + await fireEventInternal(instance, 'layout', {}, buildLayoutNativeEvent(layout)); }; /** @@ -114,22 +150,38 @@ async function fireEventInternal( init: CreateEventInit, basePayload?: NativeEventPayload, dispatchOptions?: DispatchOptions, -): Promise { +): Promise { const nativeEvent = basePayload ? mergeEventProps(basePayload, getNativeStateEventProps(instance, eventType), init.nativeEvent) : init.nativeEvent; const event = createEvent(eventType, { ...init, nativeEvent }); - if (event == null) { - return true; + if (event == null || !isInstanceMounted(instance)) { + return; } // Before the dispatch, as a device updates its native views before emitting the event. - if (isInstanceMounted(instance)) { - updateNativeStateFromEvent(instance, eventType, event); + const hasUpdatedNativeState = updateNativeStateFromEvent(instance, eventType, event); + const hasCalledHandler = await dispatchEvent(instance, event, dispatchOptions); + // The event still had an effect, e.g. `changeText` on an uncontrolled TextInput updates its value. + if (!hasCalledHandler && !hasUpdatedNativeState) { + warnAboutUnhandledEvent(instance, event); } +} - await dispatchEvent(instance, event, dispatchOptions); - return !event.defaultPrevented; +async function firePressabilityEvent( + instance: TestInstance, + eventType: PressabilityEventType, + init: CreateEventInit, +): Promise { + const event = createPressabilityEvent(eventType, init); + if (!isInstanceMounted(instance)) { + return; + } + + const hasCalledCallback = await dispatchPressabilityEvent(instance, eventType, event); + if (!hasCalledCallback) { + warnAboutUnhandledPressabilityEvent(instance, eventType); + } } function ensureInstance( diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts new file mode 100644 index 000000000..78272a704 --- /dev/null +++ b/src/events/modern/pressability.ts @@ -0,0 +1,195 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import { getEventHandlerName, getHandlerByName } from '../shared/handler'; +import { getPointerEventsBlocker } from '../shared/pointer-events'; +import type { EventHandler } from '../shared/types'; +import { resetEvent } from './dispatch'; +import type { SyntheticEvent } from './event'; +import { getEventInternals } from './event'; +import type { PressabilityEventType } from './event-types'; +import { PRESSABILITY_EVENT_TYPES } from './event-types'; + +/** Callbacks of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` calls. */ +type PressabilityCallbackName = `on${Capitalize}`; + +/** The part of React Native's `PressabilityConfig` that `dispatchPressabilityEvent()` reads. */ +type PressabilityConfig = { disabled?: boolean | null } & { + [Name in PressabilityCallbackName]?: EventHandler | null; +}; + +type DispatchState = { + hasCalledCallback: boolean; + /** Wrapped, so a thrown `undefined` is rethrown too. */ + firstError: { error: unknown } | null; +}; + +/** + * Touches the element as on a device, and calls one Pressability callback (`onPress` for `press`, + * `onPressIn` for `pressIn`, ...) of the element that becomes the touch responder: + * + * 1. Hit testing: the touch targets the element, or its nearest ancestor when `pointerEvents` + * blocks it. + * 2. Responder negotiation, as React Native's `ResponderEventPlugin` does on touch start: + * `onStartShouldSetResponder` from the target to the root. The first one returning `true` wins. + * A disabled `Pressable` declines, so the touch goes on to its ancestors. Called without + * arguments, as in legacy `fireEvent` and `userEvent`. + * 3. The responder's callback: from its `PressabilityConfig` for `Pressable` and `Touchable*`, + * which Pressability exposes in tests as `onStartShouldSetResponder.testOnly_pressabilityConfig()`. + * Responders without Pressability, like `PanResponder`, get nothing. + * + * The Jest preset mocks `Text` and `TextInput`, so their host elements get `onPress*` props + * instead of responder props. A host element with any of these props (or their `testOnly_` + * variants) takes part in the negotiation as if it had Pressability, unless it is a disabled `Text` + * or a non-editable `TextInput`, as in `userEvent.press()`. + * + * One `act()` wraps it all, and a callback error is rethrown after it. During the call, + * `event.target` is the hit target and `event.currentTarget` the responder. + * + * Not implemented yet: the capture phase (`onStartShouldSetResponderCapture`), as `PanResponder` + * reads `touchHistory` there, which only the responder system's events have. + * + * Known limitation: `testOnly_pressabilityConfig` exists only when `NODE_ENV` is `test`, which + * Jest sets by default. Without it, `Pressable` and `Touchable*` claim the touch but get nothing. + * + * @param event from `createPressabilityEvent()`, passed to the callback. React Native passes the + * responder event derived from `touchStart` to `onPressIn`, and from `touchEnd` to `onPress` and + * `onPressOut`. + * @returns `true` if a callback was called. + */ +export async function dispatchPressabilityEvent( + target: TestInstance, + eventType: PressabilityEventType, + event: SyntheticEvent, +): Promise { + if (!isInstanceMounted(target)) { + return false; + } + + const path = getTouchPath(target); + if (path.length === 0) { + return false; + } + + const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; + const state: DispatchState = { hasCalledCallback: false, firstError: null }; + await act(() => { + getEventInternals(event).target = path[0]; + getEventInternals(event).composedPath = path; + try { + const responder = findResponder(path); + const callback = responder != null ? getCallback(responder, callbackName) : undefined; + if (responder != null && callback != null) { + const phase = responder === path[0] ? event.AT_TARGET : event.BUBBLING_PHASE; + getEventInternals(event).eventPhase = phase; + getEventInternals(event).currentTarget = responder; + state.hasCalledCallback = true; + callback.call(responder, event); + } + } catch (error) { + state.firstError = { error }; + } + + resetEvent(event); + }); + + // After `act()`, which would skip rendering state updates if its callback threw. + if (state.firstError != null) { + throw state.firstError.error; + } + + return state.hasCalledCallback; +} + +/** + * Elements with the callback for the event type, from the target up to the root, whether or not + * they are disabled or blocked by `pointerEvents`. When `dispatchPressabilityEvent()` called no + * callback, these are the elements it skipped. + */ +export function getPressabilityCallbackOwners( + target: TestInstance, + eventType: PressabilityEventType, +): TestInstance[] { + const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; + const owners: TestInstance[] = []; + let current: TestInstance | null = target; + while (current?.parent != null) { + const config = getPressabilityConfig(current); + const callback = + config != null ? config[callbackName] : getHandlerByName(current.props, callbackName); + if (callback != null) { + owners.push(current); + } + + current = current.parent; + } + + return owners; +} + +/** The hit target, then its host ancestors. Empty when `pointerEvents` blocks every element. */ +function getTouchPath(target: TestInstance): TestInstance[] { + let hitTarget: TestInstance | null = target; + while (hitTarget?.parent != null && getPointerEventsBlocker(hitTarget) != null) { + hitTarget = hitTarget.parent; + } + + const path: TestInstance[] = []; + let current = hitTarget; + while (current?.parent != null) { + path.push(current); + current = current.parent; + } + + return path; +} + +function findResponder(path: TestInstance[]): TestInstance | null { + for (const node of path) { + const handler = getHandlerByName(node.props, 'onStartShouldSetResponder'); + if (handler != null ? handler.call(node) : hasHostCallback(node)) { + return node; + } + } + + return null; +} + +function getCallback( + responder: TestInstance, + callbackName: PressabilityCallbackName, +): EventHandler | undefined { + const config = getPressabilityConfig(responder); + if (config == null) { + return getHostCallback(responder, callbackName); + } + + return config.disabled !== true ? (config[callbackName] ?? undefined) : undefined; +} + +/** Config of `Pressable` and `Touchable*`, or `null` for elements without Pressability. */ +function getPressabilityConfig(instance: TestInstance): PressabilityConfig | null { + const getConfig: unknown = instance.props.onStartShouldSetResponder?.testOnly_pressabilityConfig; + return typeof getConfig === 'function' ? (getConfig() as PressabilityConfig) : null; +} + +function hasHostCallback(instance: TestInstance): boolean { + return PRESSABILITY_EVENT_TYPES.some( + (eventType) => getHostCallback(instance, getEventHandlerName(eventType)) != null, + ); +} + +/** Callback prop of a mocked `Text` or `TextInput` host, or a `testOnly_` prop. */ +function getHostCallback(instance: TestInstance, callbackName: string): EventHandler | undefined { + if (isHostText(instance) && instance.props.disabled === true) { + return undefined; + } + + if (isHostTextInput(instance) && instance.props.editable === false) { + return undefined; + } + + return getHandlerByName(instance.props, callbackName); +} diff --git a/src/events/modern/warnings.ts b/src/events/modern/warnings.ts new file mode 100644 index 000000000..daf011f03 --- /dev/null +++ b/src/events/modern/warnings.ts @@ -0,0 +1,101 @@ +import type { TestInstance } from 'test-renderer'; + +import { getConfig } from '../../config'; +import { computeAriaDisabled } from '../../helpers/accessibility'; +import { getEventHandlerName } from '../shared/handler'; +import type { EventWarning } from '../shared/warnings'; +import { + formatDisabledTargets, + getPointerEventsBlockedTargets, + logEventWarning, +} from '../shared/warnings'; +import type { SyntheticEvent } from './event'; +import { getEventInternals } from './event'; +import type { PressabilityEventType } from './event-types'; +import { getPressabilityCallbackOwners } from './pressability'; + +/** + * Warns when `dispatchEvent()` called no handler and the event updated no native state. Modern + * dispatch skips no handlers, so the only cause is that no element on the path has one. + */ +export function warnAboutUnhandledEvent(instance: TestInstance, event: SyntheticEvent) { + if (!getConfig().eventDiagnostics) { + return; + } + + const { kind, handlerName } = getEventInternals(event).typeConfig; + logEventWarning({ + message: + kind === 'direct' + ? `No "${handlerName}" handler found on the element.` + : `No "${handlerName}" handler found on the element or its ancestors.`, + elements: [instance], + }); +} + +/** Warns when `fireEvent.changeText()` skipped a non-editable `TextInput`. */ +export function warnAboutNonEditableTextInput(instance: TestInstance) { + if (!getConfig().eventDiagnostics) { + return; + } + + logEventWarning({ + message: `Cannot fire the "changeText" event on ${formatDisabledTargets([instance])}.`, + elements: [instance], + }); +} + +/** + * Warns when `dispatchPressabilityEvent()` called no callback because the elements with one are + * disabled or blocked by `pointerEvents`, or no element has one. + */ +export function warnAboutUnhandledPressabilityEvent( + instance: TestInstance, + eventType: PressabilityEventType, +) { + if (!getConfig().eventDiagnostics) { + return; + } + + const warning = getUnhandledPressabilityEventWarning(instance, eventType); + if (warning != null) { + logEventWarning(warning); + } +} + +function getUnhandledPressabilityEventWarning( + instance: TestInstance, + eventType: PressabilityEventType, +): EventWarning | null { + const skippedTargets = getPressabilityCallbackOwners(instance, eventType); + if (skippedTargets.length === 0) { + return { + message: `No "${getEventHandlerName(eventType)}" handler found on the element or its ancestors.`, + elements: [instance], + }; + } + + // `pointerEvents` is checked first: it blocks the touch even if the element is enabled. + const blocked = getPointerEventsBlockedTargets(skippedTargets); + if (blocked != null) { + return { + message: + blocked.elements.length === 1 + ? `Cannot fire the "${eventType}" event on an element blocked by pointerEvents.` + : `Cannot fire the "${eventType}" event on elements blocked by pointerEvents.`, + ...blocked, + }; + } + + // `computeAriaDisabled` also covers non-editable `TextInput`. A responder declining the touch, + // or claiming it without the callback (`PanResponder`), is deliberate, so it doesn't warn. + const disabledTargets = skippedTargets.filter(computeAriaDisabled); + if (disabledTargets.length === 0) { + return null; + } + + return { + message: `Cannot fire the "${eventType}" event on ${formatDisabledTargets(disabledTargets)}.`, + elements: disabledTargets, + }; +} diff --git a/src/events/shared/pointer-events.ts b/src/events/shared/pointer-events.ts new file mode 100644 index 000000000..3c0ad3e01 --- /dev/null +++ b/src/events/shared/pointer-events.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +/** + * pointerEvents controls whether the View can be the target of touch events. + * 'auto': The View and its children can be the target of touch events. + * 'none': The View is never the target of touch events. + * 'box-none': The View is never the target of touch events but its subviews can be + * 'box-only': The view can be the target of touch events but its subviews cannot be + * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ +export function isPointerEventEnabled(instance: TestInstance): boolean { + return getPointerEventsBlocker(instance) == null; +} + +/** + * Returns the element whose `pointerEvents` prevents the instance from being the target of + * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. + */ +export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { + return findPointerEventsBlocker(instance, false); +} + +function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { + // Check both props.pointerEvents and props.style.pointerEvents + const pointerEvents = + instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; + + const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; + + if (pointerEvents === 'none' || parentCondition) { + return instance; + } + + if (!instance.parent) { + return null; + } + + return findPointerEventsBlocker(instance.parent, true); +} diff --git a/src/events/shared/warnings.ts b/src/events/shared/warnings.ts new file mode 100644 index 000000000..35ff74266 --- /dev/null +++ b/src/events/shared/warnings.ts @@ -0,0 +1,65 @@ +import redent from 'redent'; +import type { TestInstance } from 'test-renderer'; + +import { formatElement, formatJson } from '../../helpers/format-element'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { isEditableTextInput } from '../../helpers/text-input'; +import { getPointerEventsBlocker } from './pointer-events'; + +export type EventWarning = { + message: string; + elements: TestInstance[]; + /** Elements whose `pointerEvents` blocked the event. */ + pointerEventsBlockers?: TestInstance[]; +}; + +export function logEventWarning({ message, elements, pointerEventsBlockers = [] }: EventWarning) { + const header = + `${message}\n` + + 'If this is intentional, you can disable this warning via `configure({ eventDiagnostics: false })`.'; + const elementBlocks = elements + .map((element) => element.toJSON()) + .filter((json) => json != null) + .map((json) => redent(formatJson(json), 2)); + // Blockers are often large containers, so they are printed without their children. + const blockerBlocks = [...new Set(pointerEventsBlockers)].map((blocker) => + redent(formatElement(blocker), 2), + ); + const blockerSection = blockerBlocks.length > 0 ? ['Blocked by:', ...blockerBlocks] : []; + logger.warn([header, ...elementBlocks, ...blockerSection].join('\n\n')); +} + +/** + * Returns the targets that `pointerEvents` blocks along with their blockers, or `null` if it + * blocks none of them. + * + * @param targets Skipped elements, nearest first. + */ +export function getPointerEventsBlockedTargets( + targets: TestInstance[], +): Pick | null { + const elements: TestInstance[] = []; + const pointerEventsBlockers: TestInstance[] = []; + for (const target of targets) { + const blocker = getPointerEventsBlocker(target); + if (blocker != null) { + elements.push(target); + pointerEventsBlockers.push(blocker); + } + } + + return elements.length > 0 ? { elements, pointerEventsBlockers } : null; +} + +/** + * Describes the elements that `computeAriaDisabled` treats as disabled, e.g. "a disabled element". + * Non-editable `TextInput`s are called non-editable, which is what users set (`editable={false}`). + */ +export function formatDisabledTargets(targets: TestInstance[]): string { + if (targets.every((target) => isHostTextInput(target) && !isEditableTextInput(target))) { + return targets.length === 1 ? 'a non-editable TextInput' : 'non-editable TextInputs'; + } + + return targets.length === 1 ? 'a disabled element' : 'disabled elements'; +} diff --git a/src/user-event/utils/warnings.ts b/src/user-event/utils/warnings.ts index b5eecada8..afa1a36bc 100644 --- a/src/user-event/utils/warnings.ts +++ b/src/user-event/utils/warnings.ts @@ -1,10 +1,10 @@ import { getConfig } from '../../config'; -import type { EventWarning } from '../../events/legacy'; +import type { EventWarning } from '../../events/shared/warnings'; import { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning, -} from '../../events/legacy'; +} from '../../events/shared/warnings'; import { computeAriaDisabled } from '../../helpers/accessibility'; import type { Interaction } from './interaction';