From 43b4d3bc1a40404c2174213f70e91796a6e793e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:17:55 +0200 Subject: [PATCH 1/7] base refactor --- contributing/event-dispatch.md | 17 +- contributing/native-events.md | 2 +- contributing/native-state.md | 4 +- src/__tests__/fire-event.test.tsx | 2 +- .../__tests__/dispatch.test.tsx} | 6 +- .../__tests__/handler.test.tsx} | 4 +- .../builders}/__tests__/base.test.ts | 0 .../builders}/__tests__/common.test.ts | 0 .../builders}/__tests__/index.test.ts | 0 .../builders}/__tests__/scroll.test.ts | 0 .../builders}/__tests__/text.test.ts | 0 .../builders}/base.ts | 0 .../builders}/common.ts | 0 .../builders}/index.ts | 0 .../builders}/scroll.ts | 2 +- .../builders}/text.ts | 2 +- src/events/dispatch.ts | 64 +++++ src/{event-handler.ts => events/handler.ts} | 0 src/events/index.ts | 7 + src/events/is-enabled.ts | 86 +++++++ src/{ => events}/native-state.ts | 2 +- src/events/propagation.ts | 80 ++++++ src/events/update-native-state.ts | 81 +++++++ src/fire-event.ts | 227 ++---------------- src/helpers/pointer-events.ts | 27 --- src/helpers/text-input.ts | 2 +- .../accessibility-action.ts | 3 +- src/user-event/clear.ts | 7 +- src/user-event/paste.ts | 9 +- src/user-event/press/press.ts | 9 +- src/user-event/scroll/scroll-to.ts | 5 +- src/user-event/type/type.ts | 9 +- src/user-event/utils/dispatch-event.ts | 31 --- src/user-event/utils/index.ts | 1 - 34 files changed, 383 insertions(+), 306 deletions(-) rename src/{user-event/utils/__tests__/dispatch-event.test.tsx => events/__tests__/dispatch.test.tsx} (87%) rename src/{__tests__/event-handler.test.tsx => events/__tests__/handler.test.tsx} (96%) rename src/{event-builder => events/builders}/__tests__/base.test.ts (100%) rename src/{event-builder => events/builders}/__tests__/common.test.ts (100%) rename src/{event-builder => events/builders}/__tests__/index.test.ts (100%) rename src/{event-builder => events/builders}/__tests__/scroll.test.ts (100%) rename src/{event-builder => events/builders}/__tests__/text.test.ts (100%) rename src/{event-builder => events/builders}/base.ts (100%) rename src/{event-builder => events/builders}/common.ts (100%) rename src/{event-builder => events/builders}/index.ts (100%) rename src/{event-builder => events/builders}/scroll.ts (96%) rename src/{event-builder => events/builders}/text.ts (97%) create mode 100644 src/events/dispatch.ts rename src/{event-handler.ts => events/handler.ts} (100%) create mode 100644 src/events/index.ts create mode 100644 src/events/is-enabled.ts rename src/{ => events}/native-state.ts (92%) create mode 100644 src/events/propagation.ts create mode 100644 src/events/update-native-state.ts delete mode 100644 src/helpers/pointer-events.ts delete mode 100644 src/user-event/utils/dispatch-event.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index f340cd549..ee939dbcc 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,9 +2,22 @@ RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`. +Both are built on the shared event subsystem in `src/events/`: + +| File | Contents | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `handler.ts` | Finding the `on*` handler for an event name in props | +| `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()` (own handler only) and `propagateEvent()` (`fireEvent` rules), both run in `act()` | +| `builders/` | Event payloads, matching what React Native sends on a device | +| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it | + +`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`. + ## `fireEvent` -`src/fire-event.ts` calls a single handler for a single event. The work is in finding the right handler: +`src/fire-event.ts` is the public API. It calls a single handler for a single event using `propagateEvent()`. The work is in finding the right handler: - It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements. - Direct events (see [Native event propagation](native-events.md)) only check the target. @@ -20,5 +33,5 @@ Each step uses `dispatchEvent()`, which only calls the target's own handler. It - To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action. - Keep `dispatchEvent()` simple. -- Put rules that both need, like `pointerEvents` or `editable`, in shared helpers in `src/helpers/`. +- Put rules that both need, like `pointerEvents` or `editable`, in `src/events/`. Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`. - Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior. diff --git a/contributing/native-events.md b/contributing/native-events.md index 7165104c4..4b6f27570 100644 --- a/contributing/native-events.md +++ b/contributing/native-events.md @@ -2,7 +2,7 @@ In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way. -Today, `fireEvent` treats every event as bubbling except `layout`. The list of direct events lives in `isDirectEvent()` in `src/fire-event.ts`. +Today, `fireEvent` treats every event as bubbling except `layout`. The list of direct events lives in `isDirectEvent()` in `src/events/propagation.ts`. ## Which events are which diff --git a/contributing/native-state.md b/contributing/native-state.md index cc7be3301..02e132846 100644 --- a/contributing/native-state.md +++ b/contributing/native-state.md @@ -1,6 +1,6 @@ # Native State -On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/native-state.ts`. +On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/events/native-state.ts`. ## What is stored @@ -10,7 +10,7 @@ On a device, some component state lives in native views, not in React. Jest has ## Key points -- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make. +- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make. `fireEvent` does it through `updateNativeStateFromEvent()` in `src/events/update-native-state.ts`. Each `userEvent` action writes it directly. - **Reads.** Helpers read native state, like `getTextInputValue()` in `src/helpers/text-input.ts`. Queries and matchers use those helpers instead of reading native state directly. - **Props win.** A controlled prop (like `value`) always takes precedence over native state. - **No reset.** State is stored in `WeakMap`s keyed by host instance. It disappears when the instance is unmounted, so `cleanup()` doesn't need to clear it. diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 82fbcf69e..e335d82cf 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -14,8 +14,8 @@ import { } from 'react-native'; import { fireEvent, render, screen } from '..'; +import { nativeState } from '../events'; import { _console } from '../helpers/logger'; -import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/events/__tests__/dispatch.test.tsx similarity index 87% rename from src/user-event/utils/__tests__/dispatch-event.test.tsx rename to src/events/__tests__/dispatch.test.tsx index cf8f5e49e..ea915cdf9 100644 --- a/src/user-event/utils/__tests__/dispatch-event.test.tsx +++ b/src/events/__tests__/dispatch.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { Text } from 'react-native'; -import { render, screen } from '../../..'; -import { buildTouchEvent } from '../../../event-builder'; -import { dispatchEvent } from '../dispatch-event'; +import { render, screen } from '../..'; +import { buildTouchEvent } from '../builders'; +import { dispatchEvent } from '../dispatch'; const TOUCH_EVENT = buildTouchEvent(); diff --git a/src/__tests__/event-handler.test.tsx b/src/events/__tests__/handler.test.tsx similarity index 96% rename from src/__tests__/event-handler.test.tsx rename to src/events/__tests__/handler.test.tsx index 6c615e603..31aef93ae 100644 --- a/src/__tests__/event-handler.test.tsx +++ b/src/events/__tests__/handler.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Text, View } from 'react-native'; -import { render, screen } from '..'; -import { getEventHandlerFromProps } from '../event-handler'; +import { render, screen } from '../..'; +import { getEventHandlerFromProps } from '../handler'; test('getEventHandler strict mode', async () => { const onPress = jest.fn(); diff --git a/src/event-builder/__tests__/base.test.ts b/src/events/builders/__tests__/base.test.ts similarity index 100% rename from src/event-builder/__tests__/base.test.ts rename to src/events/builders/__tests__/base.test.ts diff --git a/src/event-builder/__tests__/common.test.ts b/src/events/builders/__tests__/common.test.ts similarity index 100% rename from src/event-builder/__tests__/common.test.ts rename to src/events/builders/__tests__/common.test.ts diff --git a/src/event-builder/__tests__/index.test.ts b/src/events/builders/__tests__/index.test.ts similarity index 100% rename from src/event-builder/__tests__/index.test.ts rename to src/events/builders/__tests__/index.test.ts diff --git a/src/event-builder/__tests__/scroll.test.ts b/src/events/builders/__tests__/scroll.test.ts similarity index 100% rename from src/event-builder/__tests__/scroll.test.ts rename to src/events/builders/__tests__/scroll.test.ts diff --git a/src/event-builder/__tests__/text.test.ts b/src/events/builders/__tests__/text.test.ts similarity index 100% rename from src/event-builder/__tests__/text.test.ts rename to src/events/builders/__tests__/text.test.ts diff --git a/src/event-builder/base.ts b/src/events/builders/base.ts similarity index 100% rename from src/event-builder/base.ts rename to src/events/builders/base.ts diff --git a/src/event-builder/common.ts b/src/events/builders/common.ts similarity index 100% rename from src/event-builder/common.ts rename to src/events/builders/common.ts diff --git a/src/event-builder/index.ts b/src/events/builders/index.ts similarity index 100% rename from src/event-builder/index.ts rename to src/events/builders/index.ts diff --git a/src/event-builder/scroll.ts b/src/events/builders/scroll.ts similarity index 96% rename from src/event-builder/scroll.ts rename to src/events/builders/scroll.ts index 610d1d956..a9a799371 100644 --- a/src/event-builder/scroll.ts +++ b/src/events/builders/scroll.ts @@ -1,4 +1,4 @@ -import type { Point, Size } from '../types'; +import type { Point, Size } from '../../types'; import { baseSyntheticEvent } from './base'; /** diff --git a/src/event-builder/text.ts b/src/events/builders/text.ts similarity index 97% rename from src/event-builder/text.ts rename to src/events/builders/text.ts index 0478f9817..8b0346484 100644 --- a/src/event-builder/text.ts +++ b/src/events/builders/text.ts @@ -1,4 +1,4 @@ -import type { Size, TextRange } from '../types'; +import type { Size, TextRange } from '../../types'; import { baseSyntheticEvent } from './base'; /** diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts new file mode 100644 index 000000000..367ef36e5 --- /dev/null +++ b/src/events/dispatch.ts @@ -0,0 +1,64 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../act'; +import { isInstanceMounted } from '../helpers/component-tree'; +import type { EventHandler } from './handler'; +import { getEventHandlerFromProps } from './handler'; +import { findEventHandler } from './propagation'; + +/** + * Dispatches the event to the instance's own handler only. Used by User Event module, + * where each action does its own enabled checks. + * + * @param instance instance to trigger event on + * @param eventName name of the event + * @param event event payload(s) + */ +export async function dispatchEvent( + instance: TestInstance, + eventName: string, + ...event: unknown[] +) { + if (!isInstanceMounted(instance)) { + return; + } + + const handler = getEventHandlerFromProps(instance.props, eventName); + if (!handler) { + return; + } + + await invokeEventHandler(handler, event); +} + +/** + * Dispatches the event the way `fireEvent` does: bubbling events go up the tree to the + * nearest enabled handler, direct events only reach the target. + * + * @returns value returned by the handler + */ +export async function propagateEvent( + instance: TestInstance, + eventName: string, + ...event: unknown[] +) { + if (!isInstanceMounted(instance)) { + return; + } + + const handler = findEventHandler(instance, eventName); + if (!handler) { + return; + } + + return await invokeEventHandler(handler, event); +} + +async function invokeEventHandler(handler: EventHandler, event: unknown[]) { + let returnValue; + await act(() => { + returnValue = handler(...event); + }); + + return returnValue; +} diff --git a/src/event-handler.ts b/src/events/handler.ts similarity index 100% rename from src/event-handler.ts rename to src/events/handler.ts diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 000000000..ed679fca8 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,7 @@ +export * from './builders'; +export * from './dispatch'; +export * from './handler'; +export * from './is-enabled'; +export * from './native-state'; +export * from './propagation'; +export * from './update-native-state'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts new file mode 100644 index 000000000..f4285a838 --- /dev/null +++ b/src/events/is-enabled.ts @@ -0,0 +1,86 @@ +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 const isPointerEventEnabled = (instance: TestInstance, isParent?: boolean): boolean => { + // 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 false; + } + + if (!instance.parent) { + return true; + } + + return isPointerEventEnabled(instance.parent, true); +}; + +export function isTouchResponder(instance: TestInstance) { + return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); +} + +/** + * List of events affected by `pointerEvents` prop. + * + * Note: `fireEvent` is accepting both `press` and `onPress` for event names, + * so we need cover both forms. + */ +const eventsAffectedByPointerEventsProp = new Set(['press', 'onPress']); + +/** + * List of `TextInput` events not affected by `editable` prop. + * + * Note: `fireEvent` is accepting both `press` and `onPress` for event names, + * so we need cover both forms. + */ +const textInputEventsIgnoringEditableProp = new Set([ + 'contentSizeChange', + 'onContentSizeChange', + 'layout', + 'onLayout', + 'scroll', + 'onScroll', +]); + +/** + * Checks whether a device would deliver the event to the instance, taking into account + * `pointerEvents`, non-editable `TextInput` and touch responders that decline the touch. + */ +export function isEventEnabled( + instance: TestInstance, + eventName: string, + nearestTouchResponder?: TestInstance, +) { + if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { + return ( + isEditableTextInput(nearestTouchResponder) || + textInputEventsIgnoringEditableProp.has(eventName) + ); + } + + if (eventsAffectedByPointerEventsProp.has(eventName) && !isPointerEventEnabled(instance)) { + return false; + } + + const touchStart = nearestTouchResponder?.props.onStartShouldSetResponder?.(); + const touchMove = nearestTouchResponder?.props.onMoveShouldSetResponder?.(); + if (touchStart || touchMove) { + return true; + } + + return touchStart === undefined && touchMove === undefined; +} diff --git a/src/native-state.ts b/src/events/native-state.ts similarity index 92% rename from src/native-state.ts rename to src/events/native-state.ts index 14107f042..c4b405c7c 100644 --- a/src/native-state.ts +++ b/src/events/native-state.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import type { Point, Size } from './types'; +import type { Point, Size } from '../types'; /** * Simulated native state for unmanaged controls. diff --git a/src/events/propagation.ts b/src/events/propagation.ts new file mode 100644 index 000000000..9fad5a842 --- /dev/null +++ b/src/events/propagation.ts @@ -0,0 +1,80 @@ +import type { Fiber, TestInstance } from 'test-renderer'; + +import { formatElement } from '../helpers/format-element'; +import { logger } from '../helpers/logger'; +import type { EventHandler } from './handler'; +import { getEventHandlerFromProps } from './handler'; +import { isEventEnabled, isTouchResponder } from './is-enabled'; + +/** + * Direct events are delivered by React Native only to the emitting element and do not bubble. + * Note: `fireEvent` accepts both `layout` and `onLayout` event names, so check both forms. + */ +export function isDirectEvent(eventName: string) { + return eventName === 'layout' || eventName === 'onLayout'; +} + +/** + * Finds the handler that should receive the event, as `fireEvent` does: direct events only + * check the target, other events bubble up the tree until an enabled handler is found. + */ +export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { + return isDirectEvent(eventName) + ? getOwnEventHandler(instance, eventName) + : findBubblingEventHandler(instance, eventName); +} + +function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { + const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); + if (!handler) { + logger.warn( + `fireEvent: element has no handler for "${eventName}" event.`, + formatElement(instance), + ); + return null; + } + + return handler; +} + +function findBubblingEventHandler( + instance: TestInstance, + eventName: string, + nearestTouchResponder?: TestInstance, +): EventHandler | null { + const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; + + const handler = + getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? + findEventHandlerFromFiber(instance.unstable_fiber, eventName); + if (handler && isEventEnabled(instance, eventName, touchResponder)) { + return handler; + } + + if (instance.parent === null) { + return null; + } + + return findBubblingEventHandler(instance.parent, eventName, touchResponder); +} + +function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { + // Container fibers have memoizedProps set to null + if (!fiber?.memoizedProps) { + return null; + } + + const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { + loose: true, + }); + if (handler) { + return handler; + } + + // No parent fiber or we reached another host element + if (fiber.return === null || typeof fiber.return.type === 'string') { + return null; + } + + return findEventHandlerFromFiber(fiber.return, eventName); +} diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts new file mode 100644 index 000000000..ed3acc3fe --- /dev/null +++ b/src/events/update-native-state.ts @@ -0,0 +1,81 @@ +import type { TestInstance } from 'test-renderer'; + +import { isHostScrollView } from '../helpers/host-component-names'; +import { isEditableTextInput } from '../helpers/text-input'; +import type { Point, Size } from '../types'; +import { nativeState } from './native-state'; + +const scrollEventNames = new Set([ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', +]); + +/** + * Updates native state the way a device would have before emitting the event. + */ +export function updateNativeStateFromEvent( + instance: TestInstance, + eventName: string, + value: unknown, +) { + if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { + nativeState.valueForInstance.set(instance, value); + } + + if (scrollEventNames.has(eventName) && isHostScrollView(instance)) { + const contentOffset = tryGetContentOffset(value); + if (contentOffset) { + nativeState.contentOffsetForInstance.set(instance, contentOffset); + } + } + + if (eventName === 'layout' || eventName === 'onLayout') { + const layoutSize = tryGetLayoutSize(value); + if (layoutSize) { + nativeState.layoutSizeForInstance.set(instance, layoutSize); + } + } +} + +function tryGetContentOffset(event: unknown): Point | null { + try { + // @ts-expect-error: try to extract contentOffset from the event value + const contentOffset = event?.nativeEvent?.contentOffset; + const x = contentOffset?.x; + const y = contentOffset?.y; + + if (typeof x === 'number' || typeof y === 'number') { + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; + } + } catch { + // Do nothing + } + + return null; +} + +function tryGetLayoutSize(event: unknown): Size | null { + try { + // @ts-expect-error: try to extract layout from the event value + const layout = event?.nativeEvent?.layout; + const width = layout?.width; + const height = layout?.height; + + if (typeof width === 'number' || typeof height === 'number') { + return { + width: Number.isFinite(width) ? width : 0, + height: Number.isFinite(height) ? height : 0, + }; + } + } catch { + // Do nothing + } + + return null; +} diff --git a/src/fire-event.ts b/src/fire-event.ts index c5a6742f6..b2f6a7cc5 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -5,136 +5,20 @@ import type { TextProps, ViewProps, } from 'react-native'; -import type { Fiber, TestInstance } from 'test-renderer'; - -import { act } from './act'; -import type { LayoutRectangle } from './event-builder'; -import { buildLayoutEvent, buildScrollEvent, buildTouchEvent } from './event-builder'; -import type { EventHandler } from './event-handler'; -import { getEventHandlerFromProps } from './event-handler'; +import type { TestInstance } from 'test-renderer'; + +import type { LayoutRectangle } from './events'; +import { + buildLayoutEvent, + buildScrollEvent, + buildTouchEvent, + nativeState, + propagateEvent, + updateNativeStateFromEvent, +} from './events'; import { isInstanceMounted } from './helpers/component-tree'; -import { formatElement } from './helpers/format-element'; -import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; -import { logger } from './helpers/logger'; -import { isPointerEventEnabled } from './helpers/pointer-events'; -import { isEditableTextInput } from './helpers/text-input'; -import { nativeState } from './native-state'; -import type { Point, Size, StringWithAutocomplete } from './types'; - -function isTouchResponder(instance: TestInstance) { - return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); -} - -/** - * List of events affected by `pointerEvents` prop. - * - * Note: `fireEvent` is accepting both `press` and `onPress` for event names, - * so we need cover both forms. - */ -const eventsAffectedByPointerEventsProp = new Set(['press', 'onPress']); - -/** - * List of `TextInput` events not affected by `editable` prop. - * - * Note: `fireEvent` is accepting both `press` and `onPress` for event names, - * so we need cover both forms. - */ -const textInputEventsIgnoringEditableProp = new Set([ - 'contentSizeChange', - 'onContentSizeChange', - 'layout', - 'onLayout', - 'scroll', - 'onScroll', -]); - -function isEventEnabled( - instance: TestInstance, - eventName: string, - nearestTouchResponder?: TestInstance, -) { - if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { - return ( - isEditableTextInput(nearestTouchResponder) || - textInputEventsIgnoringEditableProp.has(eventName) - ); - } - - if (eventsAffectedByPointerEventsProp.has(eventName) && !isPointerEventEnabled(instance)) { - return false; - } - - const touchStart = nearestTouchResponder?.props.onStartShouldSetResponder?.(); - const touchMove = nearestTouchResponder?.props.onMoveShouldSetResponder?.(); - if (touchStart || touchMove) { - return true; - } - - return touchStart === undefined && touchMove === undefined; -} - -/** - * Direct events are delivered by React Native only to the emitting element and do not bubble. - * Note: `fireEvent` accepts both `layout` and `onLayout` event names, so check both forms. - */ -function isDirectEvent(eventName: string) { - return eventName === 'layout' || eventName === 'onLayout'; -} - -function getOwnEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); - if (!handler) { - logger.warn( - `fireEvent: element has no handler for "${eventName}" event.`, - formatElement(instance), - ); - return null; - } - - return handler; -} - -function findEventHandler( - instance: TestInstance, - eventName: string, - nearestTouchResponder?: TestInstance, -): EventHandler | null { - const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; - - const handler = - getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? - findEventHandlerFromFiber(instance.unstable_fiber, eventName); - if (handler && isEventEnabled(instance, eventName, touchResponder)) { - return handler; - } - - if (instance.parent === null) { - return null; - } - - return findEventHandler(instance.parent, eventName, touchResponder); -} - -function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { - // Container fibers have memoizedProps set to null - if (!fiber?.memoizedProps) { - return null; - } - - const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { - loose: true, - }); - if (handler) { - return handler; - } - - // No parent fiber or we reached another host element - if (fiber.return === null || typeof fiber.return.type === 'string') { - return null; - } - - return findEventHandlerFromFiber(fiber.return, eventName); -} +import { isHostScrollView } from './helpers/host-component-names'; +import type { StringWithAutocomplete } from './types'; // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { @@ -154,21 +38,8 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: return; } - setNativeStateIfNeeded(instance, eventName, data[0]); - - const handler = isDirectEvent(eventName) - ? getOwnEventHandler(instance, eventName) - : findEventHandler(instance, eventName); - if (!handler) { - return; - } - - let returnValue; - await act(() => { - returnValue = handler(...data); - }); - - return returnValue; + updateNativeStateFromEvent(instance, eventName, data[0]); + return await propagateEvent(instance, eventName, ...data); } type EventProps = Record; @@ -203,74 +74,6 @@ fireEvent.layout = async (instance: TestInstance, layout?: Partial, source: Record) { for (const key of Object.keys(source)) { const sourceValue = source[key]; diff --git a/src/helpers/pointer-events.ts b/src/helpers/pointer-events.ts deleted file mode 100644 index 1f7abe6e8..000000000 --- a/src/helpers/pointer-events.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 const isPointerEventEnabled = (instance: TestInstance, isParent?: boolean): boolean => { - // 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 false; - } - - if (!instance.parent) { - return true; - } - - return isPointerEventEnabled(instance.parent, true); -}; diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 2d8688b13..d053e0570 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,6 +1,6 @@ import type { TestInstance } from 'test-renderer'; -import { nativeState } from '../native-state'; +import { nativeState } from '../events/native-state'; import { isHostTextInput } from './host-component-names'; export function isEditableTextInput(instance: TestInstance) { diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts index bfa40c6fd..c32a4f0db 100644 --- a/src/user-event/accessibility-action/accessibility-action.ts +++ b/src/user-event/accessibility-action/accessibility-action.ts @@ -1,13 +1,12 @@ import type { AccessibilityActionInfo } from 'react-native'; import type { TestInstance } from 'test-renderer'; -import { buildAccessibilityActionEvent } from '../../event-builder'; +import { buildAccessibilityActionEvent, dispatchEvent } from '../../events'; import { computeAriaDisabled } from '../../helpers/accessibility'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import type { StringWithAutocomplete } from '../../types'; import type { UserEventInstance } from '../setup'; -import { dispatchEvent } from '../utils'; /** * Standard accessibility action names recognized by React Native (`activate`, diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index cd21a7c4e..54f9173cb 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -5,14 +5,15 @@ import { buildEndEditingEvent, buildFocusEvent, buildTextSelectionChangeEvent, -} from '../event-builder'; + dispatchEvent, + isPointerEventEnabled, +} from '../events'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; -import { isPointerEventEnabled } from '../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; import type { UserEventInstance } from './setup'; import { emitTypingEvents } from './type/type'; -import { dispatchEvent, wait } from './utils'; +import { wait } from './utils'; export async function clear(this: UserEventInstance, instance: TestInstance): Promise { if (!isHostTextInput(instance)) { diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index ca888c87d..fa012d016 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -7,14 +7,15 @@ import { buildFocusEvent, buildTextChangeEvent, buildTextSelectionChangeEvent, -} from '../event-builder'; + dispatchEvent, + isPointerEventEnabled, + nativeState, +} from '../events'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; -import { isPointerEventEnabled } from '../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; -import { nativeState } from '../native-state'; import type { UserEventInstance } from './setup'; -import { dispatchEvent, getTextContentSize, wait } from './utils'; +import { getTextContentSize, wait } from './utils'; export async function paste( this: UserEventInstance, diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 6408c3174..3da5a7feb 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -5,14 +5,15 @@ import { buildResponderGrantEvent, buildResponderReleaseEvent, buildTouchEvent, -} from '../../event-builder'; -import { getEventHandlerFromProps } from '../../event-handler'; + dispatchEvent, + getEventHandlerFromProps, + isPointerEventEnabled, +} from '../../events'; import { isTestInstance } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; -import { isPointerEventEnabled } from '../../helpers/pointer-events'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { dispatchEvent, wait } from '../utils'; +import { wait } from '../utils'; // These are constants defined in the React Native repo // See: https://github.com/facebook/react-native/blob/50e38cc9f1e6713228a91ad50f426c4f65e65e1a/packages/react-native/Libraries/Pressability/Pressability.js#L264 diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index f51bf6ffb..d029ffc48 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,14 +1,13 @@ import { stringify } from 'jest-matcher-utils'; import type { TestInstance } from 'test-renderer'; -import { buildScrollEvent } from '../../event-builder'; +import { buildScrollEvent, dispatchEvent, nativeState } from '../../events'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; -import { nativeState } from '../../native-state'; import type { Point, Size } from '../../types'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { dispatchEvent, wait } from '../utils'; +import { wait } from '../utils'; import { createScrollSteps, inertialInterpolator, linearInterpolator } from './utils'; interface CommonScrollToOptions { diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 1672c9bf8..94c04e8ed 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -10,14 +10,15 @@ import { buildTextChangeEvent, buildTextSelectionChangeEvent, buildTouchEvent, -} from '../../event-builder'; + dispatchEvent, + isPointerEventEnabled, + nativeState, +} from '../../events'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; -import { isPointerEventEnabled } from '../../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input'; -import { nativeState } from '../../native-state'; import type { UserEventConfig, UserEventInstance } from '../setup'; -import { dispatchEvent, getTextContentSize, wait } from '../utils'; +import { getTextContentSize, wait } from '../utils'; import { parseKeys } from './parse-keys'; export interface TypeOptions { diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts deleted file mode 100644 index 314839fb2..000000000 --- a/src/user-event/utils/dispatch-event.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TestInstance } from 'test-renderer'; - -import { act } from '../../act'; -import { getEventHandlerFromProps } from '../../event-handler'; -import { isInstanceMounted } from '../../helpers/component-tree'; - -/** - * Basic dispatch event function used by User Event module. - * - * @param instance instance to trigger event on - * @param eventName name of the event - * @param event event payload(s) - */ -export async function dispatchEvent( - instance: TestInstance, - eventName: string, - ...event: unknown[] -) { - if (!isInstanceMounted(instance)) { - return; - } - - const handler = getEventHandlerFromProps(instance.props, eventName); - if (!handler) { - return; - } - - await act(() => { - handler(...event); - }); -} diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index 28acda681..aea309381 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,3 +1,2 @@ export * from './content-size'; -export * from './dispatch-event'; export * from './wait'; From daafa268d645462143be89deb7e58da77ef02494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:24:02 +0200 Subject: [PATCH 2/7] move fire event --- contributing/event-dispatch.md | 23 ++++--- .../__tests__/fire-event.test.tsx | 6 +- src/events/builders/__tests__/index.test.ts | 1 + src/events/builders/__tests__/merge.test.ts | 35 ++++++++++ src/events/builders/index.ts | 1 + src/events/builders/merge.ts | 30 ++++++++ src/events/dispatch.ts | 37 +--------- src/{ => events}/fire-event.ts | 69 +++++++------------ src/pure.ts | 2 +- 9 files changed, 108 insertions(+), 96 deletions(-) rename src/{ => events}/__tests__/fire-event.test.tsx (99%) create mode 100644 src/events/builders/__tests__/merge.test.ts create mode 100644 src/events/builders/merge.ts rename src/{ => events}/fire-event.ts (53%) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index ee939dbcc..bc424f795 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,22 +2,23 @@ RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`. -Both are built on the shared event subsystem in `src/events/`: - -| File | Contents | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `handler.ts` | Finding the `on*` handler for an event name in props | -| `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()` (own handler only) and `propagateEvent()` (`fireEvent` rules), both run in `act()` | -| `builders/` | Event payloads, matching what React Native sends on a device | -| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it | +Both are built on the shared event subsystem in `src/events/`, which also holds `fireEvent` itself: + +| File | Contents | +| ------------------------------------------- | --------------------------------------------------------------------------------------- | +| `fire-event.ts` | Public `fireEvent` API | +| `handler.ts` | Finding the `on*` handler for an event name in props | +| `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` | +| `builders/` | Event payloads, matching what React Native sends on a device | +| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it | `src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`. ## `fireEvent` -`src/fire-event.ts` is the public API. It calls a single handler for a single event using `propagateEvent()`. The work is in finding the right handler: +`src/events/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/propagation.ts`. The work is in finding the right handler: - It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements. - Direct events (see [Native event propagation](native-events.md)) only check the target. diff --git a/src/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx similarity index 99% rename from src/__tests__/fire-event.test.tsx rename to src/events/__tests__/fire-event.test.tsx index e335d82cf..7085baf26 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -13,9 +13,9 @@ import { View, } from 'react-native'; -import { fireEvent, render, screen } from '..'; -import { nativeState } from '../events'; -import { _console } from '../helpers/logger'; +import { fireEvent, render, screen } from '../..'; +import { _console } from '../../helpers/logger'; +import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; diff --git a/src/events/builders/__tests__/index.test.ts b/src/events/builders/__tests__/index.test.ts index 8497e250a..548ed471a 100644 --- a/src/events/builders/__tests__/index.test.ts +++ b/src/events/builders/__tests__/index.test.ts @@ -13,4 +13,5 @@ test('re-exports all event builders', () => { expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function); expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function); expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function); + expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function); }); diff --git a/src/events/builders/__tests__/merge.test.ts b/src/events/builders/__tests__/merge.test.ts new file mode 100644 index 000000000..eec683e2f --- /dev/null +++ b/src/events/builders/__tests__/merge.test.ts @@ -0,0 +1,35 @@ +import { buildTouchEvent } from '../common'; +import { mergeEventProps } from '../merge'; + +test('returns the same event when no props are passed', () => { + const event = buildTouchEvent(); + const nativeEvent = { ...event.nativeEvent }; + + expect(mergeEventProps(event)).toBe(event); + expect(event.nativeEvent).toEqual(nativeEvent); +}); + +test('deep merges nested objects and keeps other default fields', () => { + const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } }); + + expect(event.nativeEvent.pageX).toBe(10); + expect(event.nativeEvent.pageY).toBe(20); + expect(event.nativeEvent.locationX).toBe(0); + expect(typeof event.preventDefault).toBe('function'); +}); + +test('replaces arrays and primitive values instead of merging them', () => { + const event = mergeEventProps(buildTouchEvent(), { + nativeEvent: { touches: [{ identifier: 1 }] }, + timeStamp: 42, + }); + + expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]); + expect(event.timeStamp).toBe(42); +}); + +test('adds props that the event does not have', () => { + const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } }); + + expect(event).toMatchObject({ custom: { value: 1 } }); +}); diff --git a/src/events/builders/index.ts b/src/events/builders/index.ts index 007e9d2c0..813d38feb 100644 --- a/src/events/builders/index.ts +++ b/src/events/builders/index.ts @@ -1,3 +1,4 @@ export * from './common'; +export * from './merge'; export * from './scroll'; export * from './text'; diff --git a/src/events/builders/merge.ts b/src/events/builders/merge.ts new file mode 100644 index 000000000..139f46e83 --- /dev/null +++ b/src/events/builders/merge.ts @@ -0,0 +1,30 @@ +type EventProps = Record; + +/** + * Deep merges custom props into a built event, so tests can override only the fields they need. + * Nested objects are merged, other values (including arrays) are replaced. Mutates and returns + * the passed event. + */ +export function mergeEventProps(event: T, eventProps?: EventProps): T { + if (eventProps) { + mergeInto(event as EventProps, eventProps); + } + + return event; +} + +function mergeInto(target: EventProps, source: EventProps) { + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = target[key]; + if (isObject(sourceValue) && isObject(targetValue)) { + mergeInto(targetValue, sourceValue); + } else { + target[key] = sourceValue; + } + } +} + +function isObject(value: unknown): value is EventProps { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts index 367ef36e5..941f90d34 100644 --- a/src/events/dispatch.ts +++ b/src/events/dispatch.ts @@ -2,13 +2,10 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../act'; import { isInstanceMounted } from '../helpers/component-tree'; -import type { EventHandler } from './handler'; import { getEventHandlerFromProps } from './handler'; -import { findEventHandler } from './propagation'; /** - * Dispatches the event to the instance's own handler only. Used by User Event module, - * where each action does its own enabled checks. + * Basic dispatch event function used by User Event module. * * @param instance instance to trigger event on * @param eventName name of the event @@ -28,37 +25,7 @@ export async function dispatchEvent( return; } - await invokeEventHandler(handler, event); -} - -/** - * Dispatches the event the way `fireEvent` does: bubbling events go up the tree to the - * nearest enabled handler, direct events only reach the target. - * - * @returns value returned by the handler - */ -export async function propagateEvent( - instance: TestInstance, - eventName: string, - ...event: unknown[] -) { - if (!isInstanceMounted(instance)) { - return; - } - - const handler = findEventHandler(instance, eventName); - if (!handler) { - return; - } - - return await invokeEventHandler(handler, event); -} - -async function invokeEventHandler(handler: EventHandler, event: unknown[]) { - let returnValue; await act(() => { - returnValue = handler(...event); + handler(...event); }); - - return returnValue; } diff --git a/src/fire-event.ts b/src/events/fire-event.ts similarity index 53% rename from src/fire-event.ts rename to src/events/fire-event.ts index b2f6a7cc5..d53fbda86 100644 --- a/src/fire-event.ts +++ b/src/events/fire-event.ts @@ -7,18 +7,15 @@ import type { } from 'react-native'; import type { TestInstance } from 'test-renderer'; -import type { LayoutRectangle } from './events'; -import { - buildLayoutEvent, - buildScrollEvent, - buildTouchEvent, - nativeState, - propagateEvent, - updateNativeStateFromEvent, -} from './events'; -import { isInstanceMounted } from './helpers/component-tree'; -import { isHostScrollView } from './helpers/host-component-names'; -import type { StringWithAutocomplete } from './types'; +import { act } from '../act'; +import { isInstanceMounted } from '../helpers/component-tree'; +import { isHostScrollView } from '../helpers/host-component-names'; +import type { StringWithAutocomplete } from '../types'; +import type { LayoutRectangle } from './builders'; +import { buildLayoutEvent, buildScrollEvent, buildTouchEvent, mergeEventProps } from './builders'; +import { nativeState } from './native-state'; +import { findEventHandler } from './propagation'; +import { updateNativeStateFromEvent } from './update-native-state'; // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { @@ -39,7 +36,18 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: } updateNativeStateFromEvent(instance, eventName, data[0]); - return await propagateEvent(instance, eventName, ...data); + + const handler = findEventHandler(instance, eventName); + if (!handler) { + return; + } + + let returnValue; + await act(() => { + returnValue = handler(...data); + }); + + return returnValue; } type EventProps = Record; @@ -48,12 +56,7 @@ fireEvent.changeText = async (instance: TestInstance, text: string) => await fireEvent(instance, 'changeText', text); fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { - const event = buildTouchEvent(); - if (eventProps) { - mergeEventProps(event, eventProps); - } - - await fireEvent(instance, 'press', event); + await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); }; fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { @@ -61,11 +64,7 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { ? nativeState.layoutSizeForInstance.get(instance) : undefined; const event = buildScrollEvent(undefined, { layoutMeasurement }); - if (eventProps) { - mergeEventProps(event, eventProps); - } - - await fireEvent(instance, 'scroll', event); + await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); }; fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { @@ -73,25 +72,3 @@ fireEvent.layout = async (instance: TestInstance, layout?: Partial, source: Record) { - for (const key of Object.keys(source)) { - const sourceValue = source[key]; - const targetValue = target[key]; - if ( - sourceValue != null && - typeof sourceValue === 'object' && - !Array.isArray(sourceValue) && - targetValue && - typeof targetValue === 'object' && - !Array.isArray(targetValue) - ) { - mergeEventProps( - targetValue as Record, - sourceValue as Record, - ); - } else { - target[key] = sourceValue; - } - } -} diff --git a/src/pure.ts b/src/pure.ts index f74996bc7..377c1f678 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { act } from './act'; export { cleanup } from './cleanup'; -export { fireEvent } from './fire-event'; +export { fireEvent } from './events/fire-event'; export { render } from './render'; export { waitFor } from './wait-for'; export { waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; From 7a2168ff001fbf2cff3b4fe87b37cd0a481ce865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:30:03 +0200 Subject: [PATCH 3/7] fixes + types.ts --- contributing/event-dispatch.md | 2 +- src/events/__tests__/dispatch.test.tsx | 62 +++++++++++++------------- src/events/__tests__/handler.test.tsx | 6 +-- src/events/builders/merge.ts | 2 +- src/events/fire-event.ts | 3 +- src/events/index.ts | 8 ++-- src/events/is-enabled.ts | 4 +- src/events/types.ts | 1 + 8 files changed, 43 insertions(+), 45 deletions(-) create mode 100644 src/events/types.ts diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index bc424f795..9dfb0876c 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -34,5 +34,5 @@ Each step uses `dispatchEvent()`, which only calls the target's own handler. It - To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action. - Keep `dispatchEvent()` simple. -- Put rules that both need, like `pointerEvents` or `editable`, in `src/events/`. Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`. +- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`. - Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior. diff --git a/src/events/__tests__/dispatch.test.tsx b/src/events/__tests__/dispatch.test.tsx index ea915cdf9..dcfd606f1 100644 --- a/src/events/__tests__/dispatch.test.tsx +++ b/src/events/__tests__/dispatch.test.tsx @@ -7,36 +7,34 @@ import { dispatchEvent } from '../dispatch'; const TOUCH_EVENT = buildTouchEvent(); -describe('dispatchEvent', () => { - it('does dispatch event', async () => { - const onPress = jest.fn(); - await render(); - - await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('does not dispatch event to parent host component', async () => { - const onPressParent = jest.fn(); - await render( - - - , - ); - - await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); - expect(onPressParent).not.toHaveBeenCalled(); - }); - - it('does NOT throw if no handler found', async () => { - await render( - - - , - ); - - await expect( - dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT), - ).resolves.not.toThrow(); - }); +test('dispatchEvent calls the target handler', async () => { + const onPress = jest.fn(); + await render(); + + await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('dispatchEvent does not call the parent host component handler', async () => { + const onPressParent = jest.fn(); + await render( + + + , + ); + + await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPressParent).not.toHaveBeenCalled(); +}); + +test('dispatchEvent does not throw when no handler is found', async () => { + await render( + + + , + ); + + await expect( + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT), + ).resolves.not.toThrow(); }); diff --git a/src/events/__tests__/handler.test.tsx b/src/events/__tests__/handler.test.tsx index 31aef93ae..b26f0f02f 100644 --- a/src/events/__tests__/handler.test.tsx +++ b/src/events/__tests__/handler.test.tsx @@ -4,7 +4,7 @@ import { Text, View } from 'react-native'; import { render, screen } from '../..'; import { getEventHandlerFromProps } from '../handler'; -test('getEventHandler strict mode', async () => { +test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); @@ -31,7 +31,7 @@ test('getEventHandler strict mode', async () => { expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress); }); -test('getEventHandler does not treat event names starting with "on" as prefixed', async () => { +test('getEventHandlerFromProps does not treat event names starting with "on" as prefixed', async () => { const onOnline = jest.fn(); // @ts-expect-error Intentionally passing such props await render(); @@ -39,7 +39,7 @@ test('getEventHandler does not treat event names starting with "on" as prefixed' expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline); }); -test('getEventHandler loose mode', async () => { +test('getEventHandlerFromProps loose mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); diff --git a/src/events/builders/merge.ts b/src/events/builders/merge.ts index 139f46e83..aa068145e 100644 --- a/src/events/builders/merge.ts +++ b/src/events/builders/merge.ts @@ -1,4 +1,4 @@ -type EventProps = Record; +import type { EventProps } from '../types'; /** * Deep merges custom props into a built event, so tests can override only the fields they need. diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index d53fbda86..57718dc41 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -15,6 +15,7 @@ import type { LayoutRectangle } from './builders'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent, mergeEventProps } from './builders'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; +import type { EventProps } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; // String union type of keys of T that start with on, stripped of 'on' @@ -50,8 +51,6 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: return returnValue; } -type EventProps = Record; - fireEvent.changeText = async (instance: TestInstance, text: string) => await fireEvent(instance, 'changeText', text); diff --git a/src/events/index.ts b/src/events/index.ts index ed679fca8..7e5961058 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,7 +1,7 @@ -export * from './builders'; +export * from './builders/common'; +export * from './builders/scroll'; +export * from './builders/text'; export * from './dispatch'; export * from './handler'; -export * from './is-enabled'; +export { isPointerEventEnabled } from './is-enabled'; export * from './native-state'; -export * from './propagation'; -export * from './update-native-state'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index f4285a838..d849f6bec 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -44,8 +44,8 @@ const eventsAffectedByPointerEventsProp = new Set(['press', 'onPress']); /** * List of `TextInput` events not affected by `editable` prop. * - * Note: `fireEvent` is accepting both `press` and `onPress` for event names, - * so we need cover both forms. + * Note: `fireEvent` accepts event names with and without the `on` prefix + * (e.g. `layout` and `onLayout`), so we need to cover both forms. */ const textInputEventsIgnoringEditableProp = new Set([ 'contentSizeChange', diff --git a/src/events/types.ts b/src/events/types.ts new file mode 100644 index 000000000..0b75eb4cc --- /dev/null +++ b/src/events/types.ts @@ -0,0 +1 @@ +export type EventProps = Record; From 67d3358d86b5351ebb0781a566c1d8a6e1e34bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:34:04 +0200 Subject: [PATCH 4/7] types --- src/events/builders/common.ts | 11 +---------- src/events/fire-event.ts | 24 +----------------------- src/events/handler.ts | 2 +- src/events/propagation.ts | 2 +- src/events/types.ts | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/events/builders/common.ts b/src/events/builders/common.ts index 614d9c370..5c9628f37 100644 --- a/src/events/builders/common.ts +++ b/src/events/builders/common.ts @@ -1,3 +1,4 @@ +import type { LayoutRectangle } from '../types'; import { baseSyntheticEvent } from './base'; /** @@ -83,16 +84,6 @@ export function buildAccessibilityActionEvent(actionName: string) { }; } -/** - * Layout rectangle of an element, as measured by the layout engine. - */ -export interface LayoutRectangle { - x: number; - y: number; - width: number; - height: number; -} - /** * Builds a layout event, as delivered to the `onLayout` handler when an element's * size or position is measured by the layout engine. diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 57718dc41..ebdbcbb33 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,36 +1,14 @@ -import type { - PressableProps, - ScrollViewProps, - TextInputProps, - TextProps, - ViewProps, -} from 'react-native'; import type { TestInstance } from 'test-renderer'; import { act } from '../act'; import { isInstanceMounted } from '../helpers/component-tree'; import { isHostScrollView } from '../helpers/host-component-names'; -import type { StringWithAutocomplete } from '../types'; -import type { LayoutRectangle } from './builders'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent, mergeEventProps } from './builders'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; -import type { EventProps } from './types'; +import type { EventName, EventProps, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; -// String union type of keys of T that start with on, stripped of 'on' -type EventNameExtractor = keyof { - [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; -}; - -type EventName = StringWithAutocomplete< - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor ->; - async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { if (!isInstanceMounted(instance)) { return; diff --git a/src/events/handler.ts b/src/events/handler.ts index 2abcd9415..806f2bbfc 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -1,4 +1,4 @@ -export type EventHandler = (...args: unknown[]) => unknown; +import type { EventHandler } from './types'; export type EventHandlerOptions = { /** Include check for event handler named without adding `on*` prefix. */ diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 9fad5a842..e453fef61 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -2,9 +2,9 @@ import type { Fiber, TestInstance } from 'test-renderer'; import { formatElement } from '../helpers/format-element'; import { logger } from '../helpers/logger'; -import type { EventHandler } from './handler'; import { getEventHandlerFromProps } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; +import type { EventHandler } from './types'; /** * Direct events are delivered by React Native only to the emitting element and do not bubble. diff --git a/src/events/types.ts b/src/events/types.ts index 0b75eb4cc..bce54d5c6 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -1 +1,36 @@ +import type { + PressableProps, + ScrollViewProps, + TextInputProps, + TextProps, + ViewProps, +} from 'react-native'; + +import type { StringWithAutocomplete } from '../types'; + +export type EventHandler = (...args: unknown[]) => unknown; + export type EventProps = Record; + +// String union type of keys of T that start with on, stripped of 'on' +type EventNameExtractor = keyof { + [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; +}; + +export type EventName = StringWithAutocomplete< + | EventNameExtractor + | EventNameExtractor + | EventNameExtractor + | EventNameExtractor + | EventNameExtractor +>; + +/** + * Layout rectangle of an element, as measured by the layout engine. + */ +export interface LayoutRectangle { + x: number; + y: number; + width: number; + height: number; +} From 7419ffb0950cffa0c346a57a4993333a19cd10ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:54:44 +0200 Subject: [PATCH 5/7] normalizeEventName --- src/events/__tests__/handler.test.tsx | 11 ++++++++++- src/events/fire-event.ts | 7 +++++-- src/events/handler.ts | 13 +++++++++++++ src/events/is-enabled.ts | 18 +++--------------- src/events/propagation.ts | 4 ++-- src/events/update-native-state.ts | 3 ++- 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/events/__tests__/handler.test.tsx b/src/events/__tests__/handler.test.tsx index b26f0f02f..f1712545e 100644 --- a/src/events/__tests__/handler.test.tsx +++ b/src/events/__tests__/handler.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '../..'; -import { getEventHandlerFromProps } from '../handler'; +import { getEventHandlerFromProps, normalizeEventName } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -67,3 +67,12 @@ test('getEventHandlerFromProps loose mode', async () => { ); expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); }); + +test('normalizeEventName strips the `on*` prefix', () => { + expect(normalizeEventName('onLayout')).toBe('layout'); + expect(normalizeEventName('onChangeText')).toBe('changeText'); + expect(normalizeEventName('layout')).toBe('layout'); + expect(normalizeEventName('changeText')).toBe('changeText'); + expect(normalizeEventName('once')).toBe('once'); + expect(normalizeEventName('on')).toBe('on'); +}); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index ebdbcbb33..af7768d96 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -4,6 +4,7 @@ import { act } from '../act'; import { isInstanceMounted } from '../helpers/component-tree'; import { isHostScrollView } from '../helpers/host-component-names'; import { buildLayoutEvent, buildScrollEvent, buildTouchEvent, mergeEventProps } from './builders'; +import { normalizeEventName } from './handler'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; import type { EventName, EventProps, LayoutRectangle } from './types'; @@ -14,9 +15,11 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: return; } - updateNativeStateFromEvent(instance, eventName, data[0]); + // `fireEvent` accepts event names with and without the `on*` prefix. + const normalizedEventName = normalizeEventName(eventName); + updateNativeStateFromEvent(instance, normalizedEventName, data[0]); - const handler = findEventHandler(instance, eventName); + const handler = findEventHandler(instance, normalizedEventName); if (!handler) { return; } diff --git a/src/events/handler.ts b/src/events/handler.ts index 806f2bbfc..1c61f2ebe 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -30,6 +30,19 @@ export function getEventHandlerFromProps( return undefined; } +/** + * Returns the event name without the `on*` prefix, e.g. `onLayout` -> `layout`. + * Note: `fireEvent` accepts event names with and without the prefix, so use this + * before comparing event names. + */ +export function normalizeEventName(eventName: string) { + if (/^on[A-Z]/.test(eventName)) { + return eventName.charAt(2).toLowerCase() + eventName.slice(3); + } + + return eventName; +} + function getEventHandlerName(eventName: string) { if (/^on[A-Z]/.test(eventName)) { return eventName; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index d849f6bec..cb7d46475 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -35,30 +35,18 @@ export function isTouchResponder(instance: TestInstance) { /** * List of events affected by `pointerEvents` prop. - * - * Note: `fireEvent` is accepting both `press` and `onPress` for event names, - * so we need cover both forms. */ -const eventsAffectedByPointerEventsProp = new Set(['press', 'onPress']); +const eventsAffectedByPointerEventsProp = new Set(['press']); /** * List of `TextInput` events not affected by `editable` prop. - * - * Note: `fireEvent` accepts event names with and without the `on` prefix - * (e.g. `layout` and `onLayout`), so we need to cover both forms. */ -const textInputEventsIgnoringEditableProp = new Set([ - 'contentSizeChange', - 'onContentSizeChange', - 'layout', - 'onLayout', - 'scroll', - 'onScroll', -]); +const textInputEventsIgnoringEditableProp = new Set(['contentSizeChange', 'layout', 'scroll']); /** * Checks whether a device would deliver the event to the instance, taking into account * `pointerEvents`, non-editable `TextInput` and touch responders that decline the touch. + * Expects event name without the `on*` prefix (see `normalizeEventName`). */ export function isEventEnabled( instance: TestInstance, diff --git a/src/events/propagation.ts b/src/events/propagation.ts index e453fef61..1d026eaa3 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -8,15 +8,15 @@ import type { EventHandler } from './types'; /** * Direct events are delivered by React Native only to the emitting element and do not bubble. - * Note: `fireEvent` accepts both `layout` and `onLayout` event names, so check both forms. */ export function isDirectEvent(eventName: string) { - return eventName === 'layout' || eventName === 'onLayout'; + return eventName === 'layout'; } /** * Finds the handler that should receive the event, as `fireEvent` does: direct events only * check the target, other events bubble up the tree until an enabled handler is found. + * Expects event name without the `on*` prefix (see `normalizeEventName`). */ export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { return isDirectEvent(eventName) diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts index ed3acc3fe..982287a6d 100644 --- a/src/events/update-native-state.ts +++ b/src/events/update-native-state.ts @@ -15,6 +15,7 @@ const scrollEventNames = new Set([ /** * Updates native state the way a device would have before emitting the event. + * Expects event name without the `on*` prefix (see `normalizeEventName`). */ export function updateNativeStateFromEvent( instance: TestInstance, @@ -32,7 +33,7 @@ export function updateNativeStateFromEvent( } } - if (eventName === 'layout' || eventName === 'onLayout') { + if (eventName === 'layout') { const layoutSize = tryGetLayoutSize(value); if (layoutSize) { nativeState.layoutSizeForInstance.set(instance, layoutSize); From c409fc7f0d72529613ba6befb70eba201327a189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 12:59:21 +0200 Subject: [PATCH 6/7] review --- src/events/__tests__/dispatch.test.tsx | 2 +- src/events/__tests__/fire-event.test.tsx | 21 +++++++++++++++++++++ src/events/fire-event.ts | 4 +++- src/events/handler.ts | 8 ++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/events/__tests__/dispatch.test.tsx b/src/events/__tests__/dispatch.test.tsx index dcfd606f1..6e00b0613 100644 --- a/src/events/__tests__/dispatch.test.tsx +++ b/src/events/__tests__/dispatch.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text } from 'react-native'; import { render, screen } from '../..'; -import { buildTouchEvent } from '../builders'; +import { buildTouchEvent } from '../builders/common'; import { dispatchEvent } from '../dispatch'; const TOUCH_EVENT = buildTouchEvent(); diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 7085baf26..76705efc7 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -194,6 +194,15 @@ describe('fireEvent.changeText', () => { 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(); @@ -324,6 +333,18 @@ describe('fireEvent.scroll', () => { }); }); + 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'], diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index af7768d96..847d70b82 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -3,7 +3,9 @@ import type { TestInstance } from 'test-renderer'; import { act } from '../act'; import { isInstanceMounted } from '../helpers/component-tree'; import { isHostScrollView } from '../helpers/host-component-names'; -import { buildLayoutEvent, buildScrollEvent, buildTouchEvent, mergeEventProps } from './builders'; +import { buildLayoutEvent, buildTouchEvent } from './builders/common'; +import { mergeEventProps } from './builders/merge'; +import { buildScrollEvent } from './builders/scroll'; import { normalizeEventName } from './handler'; import { nativeState } from './native-state'; import { findEventHandler } from './propagation'; diff --git a/src/events/handler.ts b/src/events/handler.ts index 1c61f2ebe..06bf2e006 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -36,7 +36,7 @@ export function getEventHandlerFromProps( * before comparing event names. */ export function normalizeEventName(eventName: string) { - if (/^on[A-Z]/.test(eventName)) { + if (hasOnPrefix(eventName)) { return eventName.charAt(2).toLowerCase() + eventName.slice(3); } @@ -44,13 +44,17 @@ export function normalizeEventName(eventName: string) { } function getEventHandlerName(eventName: string) { - if (/^on[A-Z]/.test(eventName)) { + if (hasOnPrefix(eventName)) { return eventName; } return `on${capitalizeFirstLetter(eventName)}`; } +function hasOnPrefix(eventName: string) { + return /^on[A-Z]/.test(eventName); +} + function capitalizeFirstLetter(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } From 597e13449c61c6fdbbc8abd90aedfae13e861e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 5 Oct 2026 13:02:23 +0200 Subject: [PATCH 7/7] fixes --- src/events/__tests__/fire-event.test.tsx | 14 ++++++++++++++ src/events/fire-event.ts | 5 ++--- src/events/propagation.ts | 10 ++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 76705efc7..bec40de84 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -37,6 +37,20 @@ test('fireEvent accepts event name with or without "on" prefix', async () => { 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(); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 847d70b82..890654726 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -18,10 +18,9 @@ async function fireEvent(instance: TestInstance, eventName: EventName, ...data: } // `fireEvent` accepts event names with and without the `on*` prefix. - const normalizedEventName = normalizeEventName(eventName); - updateNativeStateFromEvent(instance, normalizedEventName, data[0]); + updateNativeStateFromEvent(instance, normalizeEventName(eventName), data[0]); - const handler = findEventHandler(instance, normalizedEventName); + const handler = findEventHandler(instance, eventName); if (!handler) { return; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 1d026eaa3..e607f4a9a 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -2,7 +2,7 @@ import type { Fiber, TestInstance } from 'test-renderer'; import { formatElement } from '../helpers/format-element'; import { logger } from '../helpers/logger'; -import { getEventHandlerFromProps } from './handler'; +import { getEventHandlerFromProps, normalizeEventName } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; @@ -16,10 +16,12 @@ export function isDirectEvent(eventName: string) { /** * Finds the handler that should receive the event, as `fireEvent` does: direct events only * check the target, other events bubble up the tree until an enabled handler is found. - * Expects event name without the `on*` prefix (see `normalizeEventName`). + * + * Note: handlers are looked up by the event name as passed, while event rules (direct events, + * `isEventEnabled`) use the name without the `on*` prefix. */ export function findEventHandler(instance: TestInstance, eventName: string): EventHandler | null { - return isDirectEvent(eventName) + return isDirectEvent(normalizeEventName(eventName)) ? getOwnEventHandler(instance, eventName) : findBubblingEventHandler(instance, eventName); } @@ -47,7 +49,7 @@ function findBubblingEventHandler( const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? findEventHandlerFromFiber(instance.unstable_fiber, eventName); - if (handler && isEventEnabled(instance, eventName, touchResponder)) { + if (handler && isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { return handler; }