diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index f340cd549..9dfb0876c 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -2,9 +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/`, 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` calls a single handler for a single event. 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. @@ -20,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 shared helpers in `src/helpers/`. +- 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/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/events/__tests__/dispatch.test.tsx b/src/events/__tests__/dispatch.test.tsx new file mode 100644 index 000000000..6e00b0613 --- /dev/null +++ b/src/events/__tests__/dispatch.test.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Text } from 'react-native'; + +import { render, screen } from '../..'; +import { buildTouchEvent } from '../builders/common'; +import { dispatchEvent } from '../dispatch'; + +const TOUCH_EVENT = buildTouchEvent(); + +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/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx similarity index 95% rename from src/__tests__/fire-event.test.tsx rename to src/events/__tests__/fire-event.test.tsx index 82fbcf69e..bec40de84 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -13,8 +13,8 @@ import { View, } from 'react-native'; -import { fireEvent, render, screen } from '..'; -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 } } }; @@ -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(); @@ -194,6 +208,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 +347,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/__tests__/event-handler.test.tsx b/src/events/__tests__/handler.test.tsx similarity index 77% rename from src/__tests__/event-handler.test.tsx rename to src/events/__tests__/handler.test.tsx index 6c615e603..f1712545e 100644 --- a/src/__tests__/event-handler.test.tsx +++ b/src/events/__tests__/handler.test.tsx @@ -1,10 +1,10 @@ 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, normalizeEventName } 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(); @@ -67,3 +67,12 @@ test('getEventHandler 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/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 93% rename from src/event-builder/__tests__/index.test.ts rename to src/events/builders/__tests__/index.test.ts index 8497e250a..548ed471a 100644 --- a/src/event-builder/__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/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 93% rename from src/event-builder/common.ts rename to src/events/builders/common.ts index 614d9c370..5c9628f37 100644 --- a/src/event-builder/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/event-builder/index.ts b/src/events/builders/index.ts similarity index 75% rename from src/event-builder/index.ts rename to src/events/builders/index.ts index 007e9d2c0..813d38feb 100644 --- a/src/event-builder/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..aa068145e --- /dev/null +++ b/src/events/builders/merge.ts @@ -0,0 +1,30 @@ +import type { EventProps } from '../types'; + +/** + * 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/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/user-event/utils/dispatch-event.ts b/src/events/dispatch.ts similarity index 77% rename from src/user-event/utils/dispatch-event.ts rename to src/events/dispatch.ts index 314839fb2..941f90d34 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/events/dispatch.ts @@ -1,8 +1,8 @@ import type { TestInstance } from 'test-renderer'; -import { act } from '../../act'; -import { getEventHandlerFromProps } from '../../event-handler'; -import { isInstanceMounted } from '../../helpers/component-tree'; +import { act } from '../act'; +import { isInstanceMounted } from '../helpers/component-tree'; +import { getEventHandlerFromProps } from './handler'; /** * Basic dispatch event function used by User Event module. diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts new file mode 100644 index 000000000..890654726 --- /dev/null +++ b/src/events/fire-event.ts @@ -0,0 +1,55 @@ +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, 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'; +import type { EventName, EventProps, LayoutRectangle } from './types'; +import { updateNativeStateFromEvent } from './update-native-state'; + +async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { + if (!isInstanceMounted(instance)) { + return; + } + + // `fireEvent` accepts event names with and without the `on*` prefix. + updateNativeStateFromEvent(instance, normalizeEventName(eventName), data[0]); + + const handler = findEventHandler(instance, eventName); + if (!handler) { + return; + } + + let returnValue; + await act(() => { + returnValue = handler(...data); + }); + + return returnValue; +} + +fireEvent.changeText = async (instance: TestInstance, text: string) => + await fireEvent(instance, 'changeText', text); + +fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => { + await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps)); +}; + +fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { + const layoutMeasurement = isHostScrollView(instance) + ? nativeState.layoutSizeForInstance.get(instance) + : undefined; + const event = buildScrollEvent(undefined, { layoutMeasurement }); + await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); +}; + +fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { + await fireEvent(instance, 'layout', buildLayoutEvent(layout)); +}; + +export { fireEvent }; diff --git a/src/event-handler.ts b/src/events/handler.ts similarity index 66% rename from src/event-handler.ts rename to src/events/handler.ts index 2abcd9415..06bf2e006 100644 --- a/src/event-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. */ @@ -30,14 +30,31 @@ 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 (hasOnPrefix(eventName)) { + return eventName.charAt(2).toLowerCase() + eventName.slice(3); + } + + return eventName; +} + 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); } diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 000000000..7e5961058 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,7 @@ +export * from './builders/common'; +export * from './builders/scroll'; +export * from './builders/text'; +export * from './dispatch'; +export * from './handler'; +export { isPointerEventEnabled } from './is-enabled'; +export * from './native-state'; diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts new file mode 100644 index 000000000..cb7d46475 --- /dev/null +++ b/src/events/is-enabled.ts @@ -0,0 +1,74 @@ +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. + */ +const eventsAffectedByPointerEventsProp = new Set(['press']); + +/** + * List of `TextInput` events not affected by `editable` prop. + */ +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, + 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..e607f4a9a --- /dev/null +++ b/src/events/propagation.ts @@ -0,0 +1,82 @@ +import type { Fiber, TestInstance } from 'test-renderer'; + +import { formatElement } from '../helpers/format-element'; +import { logger } from '../helpers/logger'; +import { getEventHandlerFromProps, normalizeEventName } 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. + */ +export function isDirectEvent(eventName: string) { + 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. + * + * 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(normalizeEventName(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, normalizeEventName(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/types.ts b/src/events/types.ts new file mode 100644 index 000000000..bce54d5c6 --- /dev/null +++ b/src/events/types.ts @@ -0,0 +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; +} diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts new file mode 100644 index 000000000..982287a6d --- /dev/null +++ b/src/events/update-native-state.ts @@ -0,0 +1,82 @@ +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. + * Expects event name without the `on*` prefix (see `normalizeEventName`). + */ +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') { + 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 deleted file mode 100644 index c5a6742f6..000000000 --- a/src/fire-event.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { - PressableProps, - ScrollViewProps, - TextInputProps, - 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 { 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); -} - -// 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; - } - - 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; -} - -type EventProps = Record; - -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); -}; - -fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { - const layoutMeasurement = isHostScrollView(instance) - ? nativeState.layoutSizeForInstance.get(instance) - : undefined; - const event = buildScrollEvent(undefined, { layoutMeasurement }); - if (eventProps) { - mergeEventProps(event, eventProps); - } - - await fireEvent(instance, 'scroll', event); -}; - -fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEvent(instance, 'layout', buildLayoutEvent(layout)); -}; - -export { fireEvent }; - -const scrollEventNames = new Set([ - 'scroll', - 'scrollBeginDrag', - 'scrollEndDrag', - 'momentumScrollBegin', - 'momentumScrollEnd', -]); - -function setNativeStateIfNeeded(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; -} - -function mergeEventProps(target: Record, 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/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/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'; 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/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx deleted file mode 100644 index cf8f5e49e..000000000 --- a/src/user-event/utils/__tests__/dispatch-event.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from 'react'; -import { Text } from 'react-native'; - -import { render, screen } from '../../..'; -import { buildTouchEvent } from '../../../event-builder'; -import { dispatchEvent } from '../dispatch-event'; - -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(); - }); -}); 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';