diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 17a06fba7..24dcab5f2 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 legacy `fireEvent` 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. + +Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. + +`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`. Legacy helpers (touch and responder event builders) come from `src/events/legacy/index.ts`, which also re-exports `src/events/shared/handler.ts`, `src/events/shared/native-state.ts` and `isPointerEventEnabled()`. Other code imports `src/events/modern/` and `src/events/shared/` modules directly: `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`). ## `fireEvent` @@ -33,11 +37,13 @@ Code used by both event systems lives in `src/events/shared/`: `handler.ts` (fin Each step dispatches a native event, created for the configured event system by `createEvent()` (`src/events/create-event.ts`: a legacy event object or a `SyntheticEvent`) and dispatched by `dispatchEvent()` (`src/events/dispatch-event.ts`), or calls a JavaScript callback (`changeText`, `pressIn`, ...) with `invokeEventHandler()`, which only calls the target's own handler. Modern `fireEvent.changeText()` instead calls `onChangeText` from the `change` dispatch, through `dispatchEvent()`'s `afterTargetHandler` option, right after the input's own `onChange`, where `TextInput` calls it. Neither checks whether the element is enabled. Each action does those checks itself, so the rules for an interaction live in one place. +Where the two event systems need different rules, the action picks them by `getConfig().unstable_eventSystem` from `src/user-event/legacy/` or `src/user-event/modern/`, e.g. `findPressTarget()` for `press()` and `longPress()`. The modern one reuses modern `fireEvent`'s hit testing and responder negotiation (`getTouchPath()` and `findResponder()` in `src/events/modern/pressability.ts`), so both press the same element. It keeps the press on a `TextInput`, which claims the touch on a device even without `onPress*` props; the legacy one passes it on to the nearest ancestor that handles it. + For the `eventDiagnostics` warning, each action tracks itself with an `Interaction` from `src/user-event/utils/interaction.ts`: -- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut` and `longPress`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). +- Dispatch native events with `interaction.dispatchEvent(eventType, buildFocusEvent())`, using the `build*Event()` helpers from `src/events/create-event.ts`, and call JavaScript callbacks with `interaction.invokeEventHandler(eventType, ...params)` (or `interaction.dispatchTouchEvent()` for `pressIn`, `pressOut`, `longPress` and `press`, which passes a touch event), so it records whether any handler ran. Events go to `interaction.target`, which is the element the action was called with, unless the action moves it (as `press()` does when an ancestor handles the press). If the action has to call a handler itself, record it with `interaction.recordEvent()` (as `pullToRefresh()` does for `onRefresh` on the `refreshControl` prop). - Set `hasUpdatedNativeState` when the action writes to `nativeState`. -- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, non-editable `TextInput`, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. +- Add elements that could handle the action but don't accept it to `skippedTargets`: disabled, blocked by `pointerEvents`, or with a responder that declines the touch. The warning first reports the ones blocked by `pointerEvents`, with the element that blocks them (`getPointerEventsBlocker()`). Otherwise it reports the disabled ones (`computeAriaDisabled()`, which includes non-editable `TextInput`; when all of them are non-editable `TextInput`, the message calls them non-editable, see `formatDisabledTargets()`), and skips the warning if every skipped element has a responder that declines the touch. Text actions (`type()`, `clear()`, `paste()`) add the `TextInput` when it is non-editable or blocked by `pointerEvents`. In the modern event system, `press()` doesn't skip a non-editable `TextInput`: as on iOS, it calls its press callbacks, since `editable={false}` only stops it from focusing. On Android, a non-editable `TextInput` is never the touch target, so the touch goes to its ancestors; RNTL doesn't model this. Legacy `press()` skips it. - Call `warnAboutUnhandledInteraction()` from `src/user-event/utils/warnings.ts` at the end. It warns only if no handler ran and native state didn't change. ## Guidelines diff --git a/contributing/native-events.md b/contributing/native-events.md index 842b1b36f..f1ccee9f6 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -34,7 +34,7 @@ Until then, `fireEvent` logs a warning when a direct event bubbles from a nested `contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. -Both rules depend on the Jest mock. The `FlatList` cases in `src/events/legacy/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. +Both rules depend on the Jest mock. The `FlatList` cases in `src/events/__tests__/fire-event.test.tsx` cover both, so a mock change that moves these handlers fails them. ## Sources diff --git a/contributing/testing.md b/contributing/testing.md index ac38c4e2d..cbbdf0697 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/__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__/create-event.test.ts b/src/events/__tests__/create-event.test.ts index d837c4458..d37ba1a14 100644 --- a/src/events/__tests__/create-event.test.ts +++ b/src/events/__tests__/create-event.test.ts @@ -7,7 +7,6 @@ import { createEvent, buildFocusEvent, buildKeyPressEvent, - buildPressEvent, buildScrollEvent, buildSubmitEditingEvent, buildTextChangeEvent, @@ -52,7 +51,6 @@ test.each(['legacy', 'modern'] as const)( ); test.each([ - ['press', () => buildPressEvent()], ['focus', () => buildFocusEvent()], ['blur', () => buildBlurEvent()], ['accessibilityaction', () => buildAccessibilityActionEvent('increment')], diff --git a/src/events/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch-event.test.tsx index d25aee720..5ea271c28 100644 --- a/src/events/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch-event.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextInput, View } from 'react-native'; import { render, screen } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEvent } from '../create-event'; import { dispatchEvent } from '../dispatch-event'; import { SyntheticEvent } from '../modern/event'; @@ -23,7 +23,7 @@ test('dispatches the event of the selected event system', async () => { const event = createEvent('pointerDown', { pointerId: 1 }); expect(await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event)).toBe(true); expect(onTargetPointerDown).toHaveBeenCalledWith(event); - const isModern = getConfig().unstable_eventSystem === 'modern'; + const isModern = isModernEventSystem(); expect(event instanceof SyntheticEvent).toBe(isModern); // Modern goes through capture and bubble phases. Legacy calls only the target prop. expect(calls).toEqual( @@ -43,7 +43,7 @@ test('returns whether an ancestor handled the event', async () => { const event = createEvent('pointerDown', { pointerId: 1 }); const result = await dispatchEvent(screen.getByTestId('target'), 'pointerDown', event); - const isModern = getConfig().unstable_eventSystem === 'modern'; + const isModern = isModernEventSystem(); expect(result).toBe(isModern); expect(onParentPointerDown).toHaveBeenCalledTimes(isModern ? 1 : 0); }); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 07c89479b..d4bfefc2d 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,93 +1,3196 @@ 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 } from '../../config'; +import { _console, logger } from '../../helpers/logger'; +import { + isModernEventSystem as isModern, + runInLegacyEventSystem, +} from '../../test-utils/event-system'; 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 anySyntheticEvent = expect.any(SyntheticEvent); + +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); +}); + +/** 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 sets event type without the "on" prefix (legacy: passes the event object as given)', async () => { + const onFocus = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'onFocus', emptyEvent); + + // Modern matches React Native, where the event type has no "on" prefix. Legacy passes the event + // object as given, without a type. + expect(onFocus.mock.calls[0][0].type).toBe(isModern() ? 'focus' : undefined); +}); + +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.each([ + { + name: 'fireEvent.press()', + ui: (handler: jest.Mock) => , + fire: (subject: TestInstance) => fireEvent.press(subject), + }, + { + name: 'fireEvent()', + ui: (handler: jest.Mock) => , + fire: (subject: TestInstance) => fireEvent(subject, 'focus', emptyEvent), + }, +])('$name resolves to undefined when the handler returns nothing', async ({ ui, fire }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await expect(fire(screen.getByTestId('subject'))).resolves.toBeUndefined(); + expect(handler).toHaveBeenCalledTimes(1); +}); + +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)); + + expect(error).toBe( + isModern() + ? `Unable to fire a "focus" event. Unsupported event object keys: ${message}` + : undefined, + ); + expect(onFocus.mock.calls).toEqual(isModern() ? [] : [[event]]); + }, + ); + + 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); + // Legacy merges the passed props into a plain event object. + if (isModern()) return; + 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": 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('calls onPress of TextInput, not of the Pressable around it', async () => { + const onInputPress = jest.fn(); + const onPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + expect(onInputPress).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + }); + + test.each([ + ['without onPress* props', {}], + ['with only onPressIn', { onPressIn: () => {} }], + ])( + 'does not call onPress of the Pressable around TextInput %s (legacy: calls it)', + async (_, inputProps) => { + const onPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + // Modern matches React Native: TextInput's Pressability always claims the touch, so it + // never reaches the Pressable. The Jest mock of TextInput has no responder props, so legacy + // goes on to the nearest ancestor with `onPress`. + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 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 }, zoomScale: 2 }, + 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, + }, + "zoomScale": 2, + } + `); + 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); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object + // as given. + expect(onScroll.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]); + 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); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object + // as given. + expect(handler.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]); + 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('uses default payload when the event object is undefined', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); - const error = await fireEvent(screen.getByTestId('input'), 'changeText', 'Hello').then( - () => undefined, - (e: Error) => e.message, + await fireEvent.scroll(scrollView, undefined); + await fireEvent.scroll(scrollView); + + expect(onScroll).toHaveBeenCalledTimes(2); + expect(onScroll.mock.calls[0][0].nativeEvent).toEqual(onScroll.mock.calls[1][0].nativeEvent); + }); + + 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); + }, ); - const isModern = getConfig().unstable_eventSystem === 'modern'; + + 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. + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); + if (isModern()) return; + expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); + }, + ); + + 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(); +}); + +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 outcome = await fireEvent(element, 'nonExistentEvent' as any).then( + (result) => ({ result }), + (error: Error) => ({ error: error.message }), + ); + // React Native never dispatches it, so no handler runs in either. Modern throws to point out + // the mistake. + expect(outcome).toStrictEqual( + isModern() + ? { + error: `Unable to fire a "nonExistentEvent" event. React Native doesn't dispatch "nonExistentEvent" natively, so no handler would be called.`, + } + : { result: undefined }, + ); +}); - expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); +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 to the Pressable (legacy: calls onPress of disabled Text)', async () => { + const onTextPress = jest.fn(); + const onPress = jest.fn(); + await render( + + + Disabled + + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + + // 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(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }); + + test.each([ + ['aria-disabled', { 'aria-disabled': true }], + ['accessibilityState.disabled', { accessibilityState: { disabled: true } }], + ])( + 'bubbles event past Text disabled by %s to the Pressable (legacy: calls onPress of Text)', + async (_, disabledProps) => { + const onTextPress = jest.fn(); + const onPress = jest.fn(); + await render( + + + Disabled + + , + ); + + await fireEvent.press(screen.getByText('Disabled')); + + // Modern matches React Native's `Text`, which falls back to these props when `disabled` is + // not set. Legacy checks only responders, and the mocked Text has none. + expect(onTextPress).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(onPress).toHaveBeenCalledTimes(isModern() ? 1 : 0); + }, + ); + + test('calls onPress of Text with disabled={false} over aria-disabled', async () => { + const onPress = jest.fn(); + await render( + + Enabled + , + ); + + await fireEvent.press(screen.getByText('Enabled')); + + // React Native's `Text` reads `disabled` first. + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('calls onPress of non-editable TextInput without bubbling to the Pressable (legacy: bubbles)', async () => { + const onInputPress = jest.fn(); + const onPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + // Modern matches iOS: TextInput's Pressability claims the touch even when not editable, and + // calls its `onPress`, as `editable={false}` only stops it from focusing. Legacy skips the + // non-editable TextInput and bubbles to the Pressable, as Android does. + expect(onInputPress).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 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', () => { + // Modern `fireEvent` doesn't warn about unhandled events. + runInLegacyEventSystem(); + + 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 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('warns when an ancestor has only a capture handler, as no capture handlers are called', async () => { + await render( + // Spread because `View` types include `onFocusCapture` only since RN 0.88. + + + , + ); + + await fireEvent(screen.getByTestId('view'), 'focus', emptyEvent); + + expect(getWarnings()).toEqual(['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 fired on non-editable TextInput', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), 'press', emptyEvent); + + expect(onPress).not.toHaveBeenCalled(); + 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'], + ])('warns when "%s" is fired on non-editable TextInput', async (eventType, handlerName) => { + const handler = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('input'), eventType, emptyEvent); + + expect(handler).not.toHaveBeenCalled(); + 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', emptyEvent); + + 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 () => { + const onInputPress = jest.fn(); + await render( + + + , + ); + + await fireEvent.press(screen.getByTestId('input')); + + expect(onInputPress).not.toHaveBeenCalled(); + 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); + 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); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object + // as given. + expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]); + 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); + // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event + // object as given. + expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]); + 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.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)( + 'does not call the Pressable handler when TextInput without one claims "%s" (legacy: calls it)', + async (eventType) => { + const handler = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('input'), eventType, emptyEvent); + + // Modern matches React Native, where the TextInput claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test.each(['press', 'pressIn', 'pressOut', 'longPress'] as const)( + 'does not call the Pressable handler when Text without one claims "%s" (legacy: calls it)', + async (eventType) => { + const handler = jest.fn(); + const textHandlerName = eventType === 'press' ? 'onLongPress' : 'onPress'; + await render( + + Trigger + , + ); + + await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent); + + // Modern matches React Native, where a Text with a press handler claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + test.each([ + ['press', 'onLongPress'], + ['longPress', 'onPress'], + ] as const)( + 'does not call the parent handler when Pressable without one claims "%s" (legacy: calls it)', + async (eventType, innerHandlerName) => { + const handler = jest.fn(); + await render( + + + Trigger + + , + ); + + await fireEvent(screen.getByText('Trigger'), eventType, emptyEvent); + + // Modern matches React Native, where the inner Pressable claims the touch. + expect(handler).toHaveBeenCalledTimes(isModern() ? 0 : 1); + }, + ); + + 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/create-event.ts b/src/events/create-event.ts index ffb69a0ff..58aff016b 100644 --- a/src/events/create-event.ts +++ b/src/events/create-event.ts @@ -16,7 +16,6 @@ import { buildSubmitEditingNativeEvent, buildTextChangeNativeEvent, buildTextSelectionChangeNativeEvent, - buildTouchNativeEvent, } from './shared/payloads'; export type LegacyEvent = ReturnType>; @@ -48,10 +47,6 @@ export function createEvent( return createModernEvent(eventType, { nativeEvent }) as SyntheticEvent; } -export function buildPressEvent() { - return createEvent('press', buildTouchNativeEvent()); -} - export function buildFocusEvent() { return createEvent('focus', buildFocusNativeEvent()); } 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/fire-event.ts b/src/events/legacy/fire-event.ts index 306cc3c0d..d6eb9bfac 100644 --- a/src/events/legacy/fire-event.ts +++ b/src/events/legacy/fire-event.ts @@ -58,7 +58,6 @@ async function fireEventInternal(instance: TestInstance, options: FireEventOptio return; } - // `fireEvent` accepts event types with and without the `on*` prefix. const hasUpdatedNativeState = updateNativeStateFromEvent( instance, normalizeEventType(type), 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/propagation.ts b/src/events/legacy/propagation.ts index d947c5e4b..fe30ea5b7 100644 --- a/src/events/legacy/propagation.ts +++ b/src/events/legacy/propagation.ts @@ -7,7 +7,7 @@ import { isHostText, isHostTextInput, } from '../../helpers/host-component-names'; -import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler'; import type { EventHandler } from '../shared/types'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import { warnAboutBubblingDirectEvent } from './warnings'; @@ -39,7 +39,7 @@ export function findEventHandler( options: FindEventHandlerOptions, ): FindEventHandlerResult { if (!options.bubbles) { - const handler = getEventHandlerFromProps(instance.props, eventType, { loose: true }); + const handler = getEventHandlerByEventType(instance.props, eventType, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } @@ -74,7 +74,7 @@ function findBubblingHandlerOwner( const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = - getEventHandlerFromProps(instance.props, eventType, { loose: true }) ?? + getEventHandlerByEventType(instance.props, eventType, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventType); if (handler) { if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) { @@ -159,7 +159,7 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): Even return null; } - const handler = getEventHandlerFromProps(fiber.memoizedProps, eventType, { + const handler = getEventHandlerByEventType(fiber.memoizedProps, eventType, { loose: true, }); if (handler) { diff --git a/src/events/legacy/warnings.ts b/src/events/legacy/warnings.ts index 5a81beade..b1c3faa68 100644 --- a/src/events/legacy/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -3,28 +3,23 @@ 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. - */ +/** Warns when no handler ran because the target is disabled or nothing handles the event. */ export function warnAboutUnhandledEvent( instance: TestInstance, eventType: string, @@ -51,56 +46,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..178aba44e 100644 --- a/src/events/modern/dispatch.ts +++ b/src/events/modern/dispatch.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getHandlerByName } from '../shared/handler'; +import { getEventHandlerByName } from '../shared/handler'; import type { EventPhase } from './event'; import { getEventInternals, SyntheticEvent } from './event'; @@ -86,7 +86,6 @@ function getEventPath(target: TestInstance, event: SyntheticEvent): TestInstance return path; } -/** From the root down to the target, calling `on*Capture` props. Direct events skip it. */ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: DispatchState) { const config = getEventInternals(event).typeConfig; if (config.kind === 'direct') { @@ -99,7 +98,6 @@ function runCapturePhase(event: SyntheticEvent, path: TestInstance[], state: Dis } } -/** From the target up to the root, calling `on*` props. Non-bubbling events stop at the target. */ function runBubblePhase( event: SyntheticEvent, path: TestInstance[], @@ -133,7 +131,7 @@ function callHandler( getEventInternals(event).eventPhase = phase; getEventInternals(event).currentTarget = node; - const handler = getHandlerByName(node.props, handlerName); + const handler = getEventHandlerByName(node.props, handlerName); if (handler == null && afterHandler == null) { return; } @@ -147,9 +145,8 @@ function callHandler( // In the same `try`, as a component's wrapper doesn't reach its own code after the prop throws. afterHandler?.(node, event); } catch (error) { - // Keep dispatching: one failing handler doesn't stop handlers on other elements, as in React - // Native (`handleListenerError()` in `EventTarget.js`) and the DOM. `dispatchEvent()` rethrows - // the first error once the whole dispatch is done. + // Keep dispatching, as React Native (`handleListenerError()` in `EventTarget.js`) and the DOM + // do. `dispatchEvent()` rethrows the first error once the dispatch is done. if (state.firstError == null) { state.firstError = { error }; } @@ -157,7 +154,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..1021ab123 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -82,8 +82,20 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ ]; /** - * Direct events whose handler prop doesn't follow the `on` + event type pattern. + * 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. */ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { // Added in React Native 0.89. safeAreaInsetsChange: 'experimental_onSafeAreaInsetsChange', @@ -96,7 +108,6 @@ type BubblingEventTypeConfig = { handlerName: string; /** Capture phase prop, e.g. `onFocusCapture`. */ captureHandlerName: string; - /** Runs the capture phase as usual, but calls only the target when bubbling. */ skipBubbling: boolean; }; diff --git a/src/events/modern/event.ts b/src/events/modern/event.ts index 30ee22fd4..a2feb8e1d 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,8 +226,32 @@ 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; const timeStamp = init.timeStamp ?? (typeof nativeTimeStamp === 'number' ? nativeTimeStamp : undefined); diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 6da71db9c..3eed74dda 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; } @@ -77,9 +75,6 @@ export function ensureSingleEventArg( } /** - * Validates an event object of modern `fireEvent`: a plain object, with only `nativeEvent` - * (a plain object) and `timeStamp` (a finite number) keys. - * * @param callsite the function to remove from the error stack, e.g. `fireEvent` */ export function validateEventInit( diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 1a711c5df..403cf6cb8 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -3,7 +3,7 @@ import type { TestInstance } from 'test-renderer'; import { isInstanceMounted } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isEditableTextInput } from '../../helpers/text-input'; -import { getEventHandlerFromProps, normalizeEventType } from '../shared/handler'; +import { getEventHandlerByEventType, normalizeEventType } from '../shared/handler'; import { mergeEventProps } from '../shared/merge'; import { buildLayoutNativeEvent, @@ -18,13 +18,16 @@ 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'; /** * Event object passed to modern `fireEvent`. Handlers receive a `SyntheticEvent` from @@ -36,24 +39,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,44 +74,60 @@ 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; + return; } const selection = { start: text.length, end: text.length }; const payload = buildTextChangeNativeEvent(text, selection); const dispatchOptions: DispatchOptions = { afterTargetHandler: (target) => { - getEventHandlerFromProps(target.props, 'changeText')?.(text); + getEventHandlerByEventType(target.props, 'changeText')?.(text); }, }; - return await fireEventInternal(instance, 'change', {}, payload, dispatchOptions); + await fireEventInternal(instance, 'change', {}, payload, dispatchOptions); +}; + +/** + * Presses the element: calls `onPress` of the element that becomes the touch responder, as a tap + * on a device does. `Pressable` and `Touchable*` get it from their Pressability config, hosts with + * their own `onPress` (mocked `Text`, `testOnly_onPress`) directly. Disabled elements and + * responders that decline the touch pass it on to their ancestors. A `TextInput` always keeps the + * touch, even without `onPress`. `pointerEvents` moves the touch to the nearest ancestor it doesn't + * block. See `dispatchPressabilityEvent()`. + * + * Only `onPress` is called. Use `userEvent.press()` for `onPressIn`, `onPressOut` and timing. + * + * The passed `nativeEvent` is deep merged onto a default touch payload. + */ +fireEvent.press = async (instance: TestInstance, event: FireEventInit = {}): 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 +143,27 @@ 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); - } - + updateNativeStateFromEvent(instance, eventType, event); await dispatchEvent(instance, event, dispatchOptions); - return !event.defaultPrevented; +} + +async function firePressabilityEvent( + instance: TestInstance, + eventType: PressabilityEventType, + init: CreateEventInit, +): Promise { + const event = createPressabilityEvent(eventType, init); + await dispatchPressabilityEvent(instance, eventType, event); } function ensureInstance( diff --git a/src/events/modern/pressability.ts b/src/events/modern/pressability.ts new file mode 100644 index 000000000..680a217b6 --- /dev/null +++ b/src/events/modern/pressability.ts @@ -0,0 +1,180 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { isInstanceMounted } from '../../helpers/component-tree'; +import { isHostTextInput } from '../../helpers/host-component-names'; +import { isDisabledHostText } from '../../helpers/text'; +import { + getEventHandlerByName, + getEventHandlerName, + hasPressEventHandler, +} 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'; + +/** 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 = { + /** 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. A host `TextInput` always + * claims the touch, as its Pressability does on a device, even without these props. It calls its + * callbacks even when not editable, as on iOS, where `editable={false}` only stops it from + * focusing. Known difference from Android: there, a non-editable `TextInput` is disabled and never + * the touch target, so the touch goes to its ancestors. A disabled `Text` declines the touch. + * + * 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`. + */ +export async function dispatchPressabilityEvent( + target: TestInstance, + eventType: PressabilityEventType, + event: SyntheticEvent, +): Promise { + if (!isInstanceMounted(target)) { + return; + } + + const path = getTouchPath(target); + if (path.length === 0) { + return; + } + + const callbackName = getEventHandlerName(eventType) as PressabilityCallbackName; + const state: DispatchState = { 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; + 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; + } +} + +/** The hit target, then its host ancestors. Empty when `pointerEvents` blocks every element. */ +export 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; +} + +/** The first element on the touch path that claims the touch, or `null` when none does. */ +export function findResponder(path: TestInstance[]): TestInstance | null { + for (const node of path) { + const handler = getEventHandlerByName(node.props, 'onStartShouldSetResponder'); + if (handler != null ? handler.call(node) : claimsTouchWithoutResponder(node)) { + return node; + } + } + + return null; +} + +function getCallback( + responder: TestInstance, + callbackName: PressabilityCallbackName, +): EventHandler | undefined { + const config = getPressabilityConfig(responder); + if (config == null) { + return getPressabilityEventHandler(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; +} + +/** + * For mocked hosts. `TextInput` claims the touch whatever its props, as on a device, where its + * Pressability config has no `disabled`. + */ +function claimsTouchWithoutResponder(instance: TestInstance): boolean { + return isHostTextInput(instance) || hasPressabilityEventHandler(instance); +} + +function hasPressabilityEventHandler(instance: TestInstance): boolean { + return !isDisabledHostText(instance) && hasPressEventHandler(instance.props); +} + +/** + * Own `onPress*` prop (or its `testOnly_` variant) of an element without a Pressability config, + * like a mocked `Text` or `TextInput` host. `undefined` for a disabled `Text`. + */ +function getPressabilityEventHandler( + instance: TestInstance, + eventHandlerName: string, +): EventHandler | undefined { + if (isDisabledHostText(instance)) { + return undefined; + } + + return getEventHandlerByName(instance.props, eventHandlerName); +} diff --git a/src/events/shared/__tests__/handler.test.tsx b/src/events/shared/__tests__/handler.test.tsx index 53bd2e03b..052cc75bb 100644 --- a/src/events/shared/__tests__/handler.test.tsx +++ b/src/events/shared/__tests__/handler.test.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../../..'; -import { getEventHandlerFromProps, getHandlerByName, normalizeEventType } from '../handler'; +import { getEventHandlerByEventType, getEventHandlerByName, normalizeEventType } from '../handler'; -test('getEventHandlerFromProps strict mode', async () => { +test('getEventHandlerByEventType strict mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); @@ -22,24 +22,24 @@ test('getEventHandlerFromProps strict mode', async () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandlerFromProps(regular.props, 'press')).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'press')).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'press')).toBe(testOnlyOnPress); + expect(getEventHandlerByEventType(both.props, 'press')).toBe(onPress); - expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'onPress')).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'onPress')).toBe(testOnlyOnPress); + expect(getEventHandlerByEventType(both.props, 'onPress')).toBe(onPress); }); -test('getEventHandlerFromProps does not treat event names starting with "on" as prefixed', async () => { +test('getEventHandlerByEventType does not treat event names starting with "on" as prefixed', async () => { const onOnline = jest.fn(); // @ts-expect-error Intentionally passing such props await render(); - expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline); + expect(getEventHandlerByEventType(screen.getByTestId('view').props, 'online')).toBe(onOnline); }); -test('getEventHandlerFromProps loose mode', async () => { +test('getEventHandlerByEventType loose mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); @@ -57,18 +57,20 @@ test('getEventHandlerFromProps loose mode', async () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandlerFromProps(both.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe( + testOnlyOnPress, + ); + expect(getEventHandlerByEventType(both.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(regular.props, 'onPress', { loose: true })).toBe(onPress); - expect(getEventHandlerFromProps(testOnly.props, 'onPress', { loose: true })).toBe( + expect(getEventHandlerByEventType(regular.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(testOnly.props, 'onPress', { loose: true })).toBe( testOnlyOnPress, ); - expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerByEventType(both.props, 'onPress', { loose: true })).toBe(onPress); }); -test('getEventHandlerFromProps loose mode matches handlers named without the `on*` prefix', async () => { +test('getEventHandlerByEventType loose mode matches handlers named without the `on*` prefix', async () => { const press = jest.fn(); const testOnlyPress = jest.fn(); @@ -84,11 +86,11 @@ test('getEventHandlerFromProps loose mode matches handlers named without the `on const regular = screen.getByTestId('regular'); const testOnly = screen.getByTestId('testOnly'); - expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(press); - expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress); + expect(getEventHandlerByEventType(regular.props, 'press', { loose: true })).toBe(press); + expect(getEventHandlerByEventType(testOnly.props, 'press', { loose: true })).toBe(testOnlyPress); - expect(getEventHandlerFromProps(regular.props, 'press')).toBeUndefined(); - expect(getEventHandlerFromProps(testOnly.props, 'press')).toBeUndefined(); + expect(getEventHandlerByEventType(regular.props, 'press')).toBeUndefined(); + expect(getEventHandlerByEventType(testOnly.props, 'press')).toBeUndefined(); }); test('normalizeEventType strips the `on*` prefix', () => { @@ -100,7 +102,7 @@ test('normalizeEventType strips the `on*` prefix', () => { expect(normalizeEventType('on')).toBe('on'); }); -test('getHandlerByName returns function props with the exact name or its testOnly_ variant', () => { +test('getEventHandlerByName returns function props with the exact name or its testOnly_ variant', () => { const onFocus = jest.fn(); const testOnlyOnBlur = jest.fn(); const props = { @@ -110,8 +112,8 @@ test('getHandlerByName returns function props with the exact name or its testOnl onLayout: 'not a function', }; - expect(getHandlerByName(props, 'onFocus')).toBe(onFocus); - expect(getHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur); - expect(getHandlerByName(props, 'focus')).toBeUndefined(); - expect(getHandlerByName(props, 'onLayout')).toBeUndefined(); + expect(getEventHandlerByName(props, 'onFocus')).toBe(onFocus); + expect(getEventHandlerByName(props, 'onBlur')).toBe(testOnlyOnBlur); + expect(getEventHandlerByName(props, 'focus')).toBeUndefined(); + expect(getEventHandlerByName(props, 'onLayout')).toBeUndefined(); }); diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index ca6ce5a4e..9406ae522 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -5,14 +5,25 @@ type EventHandlerOptions = { loose?: boolean; }; -export function getEventHandlerFromProps( +export function getEventHandlerByEventType( props: Record, eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { + const eventHandlerName = getEventHandlerName(eventType); return ( - getHandlerByName(props, getEventHandlerName(eventType)) ?? - (options?.loose ? getHandlerByName(props, eventType) : undefined) + getEventHandlerByName(props, eventHandlerName) ?? + (options?.loose ? getEventHandlerByName(props, eventType) : undefined) + ); +} + +/** + * Whether the props include a Pressability callback (`onPress`, `onPressIn`, `onPressOut` or + * `onLongPress`), or its `testOnly_` variant. + */ +export function hasPressEventHandler(props: Record): boolean { + return ['press', 'pressIn', 'pressOut', 'longPress'].some( + (eventType) => getEventHandlerByEventType(props, eventType) != null, ); } @@ -20,14 +31,17 @@ export function getEventHandlerFromProps( * Returns the handler prop with exactly the given name (e.g. `onFocusCapture`), or else its * `testOnly_` variant (`testOnly_onFocusCapture`), which lets tests handle events on host elements. */ -export function getHandlerByName( +export function getEventHandlerByName( props: Record, - handlerName: string, + eventHandlerName: string, ): EventHandler | undefined { - return getHandlerProp(props, handlerName) ?? getHandlerProp(props, `testOnly_${handlerName}`); + return ( + getFunctionProp(props, eventHandlerName) ?? + getFunctionProp(props, `testOnly_${eventHandlerName}`) + ); } -function getHandlerProp( +function getFunctionProp( props: Record, propName: string, ): EventHandler | undefined { diff --git a/src/events/shared/invoke-event-handler.ts b/src/events/shared/invoke-event-handler.ts index d152acff6..d8bdd1bee 100644 --- a/src/events/shared/invoke-event-handler.ts +++ b/src/events/shared/invoke-event-handler.ts @@ -2,7 +2,7 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; import { isInstanceMounted } from '../../helpers/component-tree'; -import { getEventHandlerFromProps } from './handler'; +import { getEventHandlerByEventType } from './handler'; /** * Calls the element's own handler prop for the event type (`onChangeText`, or @@ -22,7 +22,7 @@ export async function invokeEventHandler( return false; } - const handler = getEventHandlerFromProps(instance.props, eventType); + const handler = getEventHandlerByEventType(instance.props, eventType); if (!handler) { return false; } diff --git a/src/events/shared/payloads.ts b/src/events/shared/payloads.ts index 8e668971e..efa2f15f1 100644 --- a/src/events/shared/payloads.ts +++ b/src/events/shared/payloads.ts @@ -44,8 +44,6 @@ export function buildBlurNativeEvent() { } /** - * Delivered to `onAccessibilityAction` when an assistive technology triggers an action. - * * Experimental values: * - `{"actionName": "increment"}` */ 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/helpers/text.ts b/src/helpers/text.ts new file mode 100644 index 000000000..ca5351687 --- /dev/null +++ b/src/helpers/text.ts @@ -0,0 +1,17 @@ +import type { TestInstance } from 'test-renderer'; + +import { isHostText } from './host-component-names'; + +/** + * Whether the element is a host `Text` that React Native's `Text` disables. It reads `disabled`, + * then `aria-disabled`, then `accessibilityState.disabled`, so `disabled={false}` wins over + * `aria-disabled`. The Jest preset mocks `Text`, so its host element gets these props unchanged. + */ +export function isDisabledHostText(instance: TestInstance): boolean { + if (!isHostText(instance)) { + return false; + } + + const { props } = instance; + return (props.disabled ?? props['aria-disabled'] ?? props.accessibilityState?.disabled) === true; +} diff --git a/src/test-utils/event-system.ts b/src/test-utils/event-system.ts index f3043be1c..d45709b03 100644 --- a/src/test-utils/event-system.ts +++ b/src/test-utils/event-system.ts @@ -1,4 +1,9 @@ -import { configure } from '../config'; +import { configure, getConfig } from '../config'; + +/** Whether the test runs in the modern event system, for tests that branch on it. */ +export function isModernEventSystem() { + return getConfig().unstable_eventSystem === 'modern'; +} /** * Runs tests in the legacy event system in both Jest projects, for legacy behavior the modern diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 16e581295..78322cb93 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -206,11 +206,9 @@ describe('clear()', () => { const user = userEvent.setup(); await user.clear(screen.getByTestId('input')); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = ['focus', 'keyPress', 'change', 'endEditing', 'blur']; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('sets native state value for unmanaged text inputs', async () => { diff --git a/src/user-event/__tests__/event-diagnostics.test.tsx b/src/user-event/__tests__/event-diagnostics.test.tsx index b82e80638..5209c8974 100644 --- a/src/user-event/__tests__/event-diagnostics.test.tsx +++ b/src/user-event/__tests__/event-diagnostics.test.tsx @@ -4,6 +4,7 @@ import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; import { configure } from '../../config'; import { logger } from '../../helpers/logger'; +import { isModernEventSystem } from '../../test-utils/event-system'; let warnSpy: jest.SpyInstance; @@ -182,17 +183,69 @@ test('warns when clearing or pasting into a non-editable TextInput', async () => ); }); -test('warns when pressing a non-editable TextInput', async () => { +test('does not warn when pressing a non-editable TextInput (legacy: warns it is non-editable)', async () => { + const onPressIn = jest.fn(); + await render(); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('input')); + + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + const isModern = isModernEventSystem(); + expect(onPressIn).toHaveBeenCalledTimes(isModern ? 1 : 0); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 0 : 1); + if (isModern) return; + expect(warnSpy.mock.calls[0][0]).toMatch( + /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + ); +}); + +test('warns when pressing a TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => { + const onPress = jest.fn(); await render( - , + + + , ); const user = userEvent.setup(); await user.press(screen.getByTestId('input')); - expect(warnSpy).toHaveBeenCalledTimes(1); + // Modern matches React Native: the TextInput claims the touch, so the warning names it, not the + // Pressable. Legacy passes the press on to the Pressable. + const isModern = isModernEventSystem(); + expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); + if (!isModern) return; + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + "press() interaction did not call any event handlers. The element has no handler for the "pressIn", "pressOut" or "press" events. + If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. + + " + `); +}); + +test('warns when pressing a non-editable TextInput without handlers inside a Pressable (legacy: calls onPress of the Pressable)', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + const user = userEvent.setup(); + + await user.press(screen.getByTestId('input')); + + // Modern matches iOS: same as an editable TextInput, it claims the touch, so the warning names + // it. Legacy skips the non-editable TextInput and passes the press on to the Pressable. + const isModern = isModernEventSystem(); + expect(onPress).toHaveBeenCalledTimes(isModern ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern ? 1 : 0); + if (!isModern) return; expect(warnSpy.mock.calls[0][0]).toMatch( - /^press\(\) interaction did not call any event handlers\. Cannot interact with a non-editable TextInput\./, + /^press\(\) interaction did not call any event handlers\. The element has no handler for the "pressIn", "pressOut" or "press" events\./, ); }); diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index 289919dea..e4b30fa54 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { getConfig } from '../../config'; +import { isModernEventSystem } from '../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { @@ -222,11 +222,9 @@ describe('paste()', () => { const user = userEvent.setup(); await user.paste(screen.getByTestId('input'), 'Hi!'); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = ['focus', 'change', 'endEditing', 'blur']; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('sets native state value for unmanaged text inputs', async () => { diff --git a/src/user-event/legacy/press.ts b/src/user-event/legacy/press.ts new file mode 100644 index 000000000..5ed0a7dcc --- /dev/null +++ b/src/user-event/legacy/press.ts @@ -0,0 +1,63 @@ +import type { TestInstance } from 'test-renderer'; + +import { hasPressEventHandler, isPointerEventEnabled } from '../../events/legacy'; +import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import type { Interaction } from '../utils'; + +/** + * Finds the element that receives the press, from the instance up to the root, and sets it as + * `interaction.target`: the nearest enabled host element with `onPress*` props (`'host'`), or + * touch responder that accepts the touch (`'responder'`). Elements that could handle the press, + * but are disabled or block touches, are added to `interaction.skippedTargets`. + * + * A non-editable `TextInput` is skipped, and a `TextInput` without `onPress*` props passes the + * press on to its ancestors. Modern `findPressTarget()` keeps it, as React Native does. + * + * @returns how to press the target, or `null` when no element receives the press. + */ +export function findPressTarget( + interaction: Interaction, + instance: TestInstance, +): 'host' | 'responder' | null { + let current: TestInstance | null = instance; + while (current != null) { + if (isEnabledHostElement(current) && hasPressEventHandler(current.props)) { + interaction.target = current; + return 'host'; + } + + if (isEnabledTouchResponder(current)) { + interaction.target = current; + return 'responder'; + } + + // The element could handle the press, but is disabled or blocks touches. + if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) { + interaction.skippedTargets.push(current); + } + + current = current.parent; + } + + return null; +} + +function isEnabledHostElement(instance: TestInstance) { + if (!isPointerEventEnabled(instance)) { + return false; + } + + if (isHostText(instance)) { + return instance.props.disabled !== true; + } + + if (isHostTextInput(instance)) { + return instance.props.editable !== false; + } + + return true; +} + +function isEnabledTouchResponder(instance: TestInstance) { + return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.(); +} diff --git a/src/user-event/modern/press.ts b/src/user-event/modern/press.ts new file mode 100644 index 000000000..1a06aa509 --- /dev/null +++ b/src/user-event/modern/press.ts @@ -0,0 +1,40 @@ +import type { TestInstance } from 'test-renderer'; + +import { findResponder, getTouchPath } from '../../events/modern/pressability'; +import { getEventHandlerByName, hasPressEventHandler } from '../../events/shared/handler'; +import type { Interaction } from '../utils'; + +/** + * Sets `interaction.target` to the touch responder, the element modern `fireEvent.press()` calls + * (see `dispatchPressabilityEvent()`). A responder with `onStartShouldSetResponder` (`Pressable`, + * `Touchable*`, `PanResponder`) is pressed with responder events (`'responder'`); a mocked host + * without one, like `Text` or `TextInput`, gets its `onPress*` props called (`'host'`). Elements + * between the pressed element and the responder that could handle the press, but are disabled or + * block touches, are added to `interaction.skippedTargets`. + * + * @returns how to press the target, or `null` when no element receives the press. + */ +export function findPressTarget( + interaction: Interaction, + instance: TestInstance, +): 'host' | 'responder' | null { + const touchResponder = findResponder(getTouchPath(instance)); + + let current: TestInstance | null = instance; + while (current != null && current !== touchResponder) { + if (hasPressEventHandler(current.props) || current.props.onStartShouldSetResponder) { + interaction.skippedTargets.push(current); + } + + current = current.parent; + } + + if (touchResponder == null) { + return null; + } + + interaction.target = touchResponder; + return getEventHandlerByName(touchResponder.props, 'onStartShouldSetResponder') != null + ? 'responder' + : 'host'; +} diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index db7be70c4..674005fd0 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -11,6 +11,7 @@ import { import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; // Typical event order is pressIn, pressOut, press @@ -272,7 +273,7 @@ describe('userEvent.press with real timers', () => { expect(events).toEqual([]); }); - test('does not call onPressIn and onPressOut on non editable TextInput', async () => { + test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => { const { events, logEvent } = createEventLogger(); await render( @@ -284,7 +285,8 @@ describe('userEvent.press with real timers', () => { />, ); await userEvent.press(screen.getByPlaceholderText('email')); - expect(events).toEqual([]); + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []); }); test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => { diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 7ab2eb80a..d86918525 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -11,6 +11,7 @@ import { import { userEvent } from '../..'; import { render, screen } from '../../..'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames } from '../../../test-utils/events'; describe('userEvent.press with fake timers', () => { @@ -82,6 +83,54 @@ describe('userEvent.press with fake timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); + test('passes the same touch event to onPress of Text as to onPressIn', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + + await userEvent.press(screen.getByText('press me')); + const [pressInEvent, pressEvent] = events.map((event) => event.payload); + expect(pressEvent).toMatchObject({ + target: {}, + currentTarget: { measure: expect.any(Function) }, + nativeEvent: pressInEvent.nativeEvent, + }); + }); + + test('does not call onPress of the Text around pressed Text', async () => { + const onPressOuter = jest.fn(); + const onPressInner = jest.fn(); + + await render( + + outer inner + , + ); + + await userEvent.press(screen.getByText('inner')); + expect(onPressInner).toHaveBeenCalledTimes(1); + expect(onPressOuter).not.toHaveBeenCalled(); + }); + + test('does not call testOnly_onPress of the View around pressed View', async () => { + const onPressOuter = jest.fn(); + const onPressInner = jest.fn(); + + await render( + + + , + ); + + await userEvent.press(screen.getByTestId('inner')); + expect(onPressInner).toHaveBeenCalledTimes(1); + expect(onPressOuter).not.toHaveBeenCalled(); + }); + test('works on TextInput', async () => { const { events, logEvent } = createEventLogger(); @@ -218,6 +267,29 @@ describe('userEvent.press with fake timers', () => { expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); }); + test('calls press on pressable with pointer events box-none from its child (legacy: does not call)', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + await render( + + Trigger + , + ); + await user.press(screen.getByText('Trigger')); + + // Modern matches React Native: `box-none` only stops hit testing on the `Pressable` itself, so + // its child is the hit target and the `Pressable` still claims the touch as the responder. + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? ['pressIn', 'pressOut', 'press'] : [], + ); + }); + test('crawls up in the tree to find an element that responds to touch events', async () => { const mockOnPress = jest.fn(); @@ -281,6 +353,46 @@ describe('userEvent.press with fake timers', () => { expect(events).toEqual([]); }); + test.each([ + ['aria-disabled', { 'aria-disabled': true }], + ['accessibilityState.disabled', { accessibilityState: { disabled: true } }], + ])('does not trigger on Text disabled by %s (legacy: triggers)', async (_, disabledProps) => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + await userEvent.press(screen.getByText('press me')); + + // Modern matches React Native's `Text`, which falls back to these props when `disabled` is + // not set. Legacy checks only `disabled`. + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? [] : ['pressIn', 'pressOut', 'press'], + ); + }); + + test('triggers on Text with disabled={false} over aria-disabled', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + press me + , + ); + await userEvent.press(screen.getByText('press me')); + + // React Native's `Text` reads `disabled` first. + expect(getEventsNames(events)).toEqual(['press']); + }); + test('does not trigger on Text with disabled pointer events', async () => { const { events, logEvent } = createEventLogger(); @@ -301,7 +413,41 @@ describe('userEvent.press with fake timers', () => { expect(events).toEqual([]); }); - test('does not call onPressIn and onPressOut on non editable TextInput', async () => { + test('does not call onPress of the Pressable around TextInput (legacy: calls it for TextInput without onPress* props)', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + + + , + ); + + // Modern matches React Native: TextInput's Pressability always claims the touch, even without + // `onPress*` props, so it never reaches the Pressable. + await userEvent.press(screen.getByPlaceholderText('plain')); + await userEvent.press(screen.getByPlaceholderText('with pressIn')); + expect(getEventsNames(events)).toEqual( + isModernEventSystem() ? ['inputPressIn'] : ['press', 'inputPressIn'], + ); + }); + + test('calls onPressIn of non editable TextInput, not onPress of the Pressable around it (legacy: the reverse)', async () => { + const { events, logEvent } = createEventLogger(); + + await render( + + + , + ); + + await userEvent.press(screen.getByPlaceholderText('email')); + // Modern matches iOS: a non-editable TextInput claims the touch too, and calls its callbacks, + // as `editable={false}` only stops it from focusing. Legacy skips it, as Android does. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['inputPressIn'] : ['press']); + }); + + test('calls onPressIn and onPressOut on non editable TextInput (legacy: calls nothing)', async () => { const { events, logEvent } = createEventLogger(); await render( @@ -314,7 +460,8 @@ describe('userEvent.press with fake timers', () => { ); await userEvent.press(screen.getByPlaceholderText('email')); - expect(events).toEqual([]); + // Modern matches iOS, where `editable={false}` only stops TextInput from focusing. + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? ['pressIn', 'pressOut'] : []); }); test('does not call onPressIn and onPressOut on TextInput with pointer events disabled', async () => { diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 5d9aa602d..95e172564 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,16 +1,12 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../../act'; -import { buildPressEvent } from '../../events/create-event'; -import { - buildResponderGrantEvent, - buildResponderReleaseEvent, - getEventHandlerFromProps, - isPointerEventEnabled, -} from '../../events/legacy'; +import { getConfig } from '../../config'; +import { buildResponderGrantEvent, buildResponderReleaseEvent } from '../../events/legacy'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; -import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; +import { findPressTarget as findLegacyPressTarget } from '../legacy/press'; +import { findPressTarget as findModernPressTarget } from '../modern/press'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { Interaction, wait, warnAboutUnhandledInteraction } from '../utils'; @@ -63,59 +59,16 @@ const basePress = async ( instance: TestInstance, options: BasePressOptions, ): Promise => { - if (isEnabledHostElement(instance) && hasPressEventHandler(instance)) { - interaction.target = instance; + const findPressTarget = + getConfig().unstable_eventSystem === 'modern' ? findModernPressTarget : findLegacyPressTarget; + const targetKind = findPressTarget(interaction, instance); + if (targetKind === 'host') { await emitDirectPressEvents(config, interaction, options); - return; - } - - if (isEnabledTouchResponder(instance)) { - interaction.target = instance; + } else if (targetKind === 'responder') { await emitPressabilityPressEvents(config, interaction, options); - return; - } - - // The element could handle the press, but is disabled or blocks touches. - if (hasPressEventHandler(instance) || instance.props.onStartShouldSetResponder) { - interaction.skippedTargets.push(instance); - } - - if (!instance.parent) { - return; } - - await basePress(config, interaction, instance.parent, options); }; -function isEnabledHostElement(instance: TestInstance) { - if (!isPointerEventEnabled(instance)) { - return false; - } - - if (isHostText(instance)) { - return instance.props.disabled !== true; - } - - if (isHostTextInput(instance)) { - return instance.props.editable !== false; - } - - return true; -} - -function isEnabledTouchResponder(instance: TestInstance) { - return isPointerEventEnabled(instance) && instance.props.onStartShouldSetResponder?.(); -} - -function hasPressEventHandler(instance: TestInstance) { - return ( - getEventHandlerFromProps(instance.props, 'press') || - getEventHandlerFromProps(instance.props, 'longPress') || - getEventHandlerFromProps(instance.props, 'pressIn') || - getEventHandlerFromProps(instance.props, 'pressOut') - ); -} - /** * Dispatches a press event sequence for host instances that have `onPress*` event handlers. */ @@ -140,8 +93,10 @@ async function emitDirectPressEvents( // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. + // Like the touch callbacks above, `press` is called only on the target, with a touch event. + // Dispatching it would bubble it to `onPress` of ancestors, which the responder system doesn't do. if (options.type === 'press') { - await interaction.dispatchEvent('press', buildPressEvent()); + await interaction.dispatchTouchEvent('press'); } } diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 88f09a4ba..3d70f7667 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -4,7 +4,7 @@ import { TextInput, View } from 'react-native'; import { userEvent } from '../..'; import { render, screen } from '../../..'; -import { getConfig } from '../../../config'; +import { isModernEventSystem } from '../../../test-utils/event-system'; import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events'; beforeEach(() => { @@ -351,16 +351,14 @@ describe('type()', () => { const user = userEvent.setup(); await user.type(screen.getByTestId('input'), 'abc'); - // `focus`, `keyPress`, `change`, `endEditing` and `blur` are bubbling events in React Native. + // These bubble to the parent in React Native. const modernEvents = [ 'focus', ...['keyPress', 'change', 'keyPress', 'change', 'keyPress', 'change'], 'endEditing', 'blur', ]; - expect(getEventsNames(events)).toEqual( - getConfig().unstable_eventSystem === 'modern' ? modernEvents : [], - ); + expect(getEventsNames(events)).toEqual(isModernEventSystem() ? modernEvents : []); }); test('supports direct access', async () => { diff --git a/src/user-event/utils/interaction.ts b/src/user-event/utils/interaction.ts index 6a5c539a0..e986e2a3a 100644 --- a/src/user-event/utils/interaction.ts +++ b/src/user-event/utils/interaction.ts @@ -35,7 +35,7 @@ export class Interaction { /** * Calls a touch callback that `Pressable` calls from JavaScript (`pressIn`, `pressOut`, - * `longPress`) with a touch event, the way Pressability does. + * `longPress`, `press`) with a touch event, the way Pressability does. */ async dispatchTouchEvent(eventName: string) { await this.invokeEventHandler(eventName, buildTouchEvent()); 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';