diff --git a/CHANGELOG.md b/CHANGELOG.md index 2816691b5..083ad6f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ with v14. ### Features - Added `fireEvent.layout()` to simulate the layout engine measuring an element, invoking the - `onLayout` handler with a synthetic layout event. Layout events do not bubble to parent - elements. + `onLayout` handler with a synthetic layout event. Unlike `fireEvent(element, 'layout')`, it + does not bubble to parent elements. - `fireEvent.scroll()` and `userEvent.scrollTo()` use the size from the last layout event on the same `ScrollView` as the default `layoutMeasurement`. - Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an @@ -17,6 +17,14 @@ with v14. - Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. +### Deprecations + +- `fireEvent` warns when a direct event bubbles from a nested element to the host element that + emits it, e.g. `scroll` from `ScrollView` content to the `ScrollView`. These events will stop + bubbling in the next major version. See the + [`fireEvent` docs](./website/docs/14.x/docs/api/events/fire-event.mdx) for the list of direct + events. + ## 14.0.0 ### Migration guide diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index 7a0bb6981..db90c0c6b 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -22,7 +22,7 @@ Both are built on the shared event subsystem in `src/events/`, which also holds `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. +- Direct events (see [Native event propagation](native-events.md)) still bubble, with a warning when they reach an ancestor that emits them. `fireEvent.layout()` only checks the target. - It mimics cases where a device would not deliver the event, like `pointerEvents`, a non-editable `TextInput`, or a touch responder that declines. ## `userEvent` diff --git a/contributing/native-events.md b/contributing/native-events.md index 4b6f27570..56cb477f7 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/events/propagation.ts`. +Today, `fireEvent` still bubbles direct events, with a warning (see [Known gaps](#known-gaps)). Only `fireEvent.layout()` does not bubble. The rules live in `isDirectEvent()` in `src/events/propagation.ts`. ## Which events are which @@ -26,7 +26,13 @@ This is simplified. A few events differ between iOS and Android. Check the sourc ## Known gaps -All the direct events above except `layout` still bubble in `fireEvent`. Fixing that is a breaking change: tests that fire these events on a child element would stop reaching the parent's handler. +`fireEvent` still bubbles the direct events above for backward compatibility, as tests fire them on nested elements, e.g. `scroll` on `ScrollView` content. Making them direct is a breaking change, planned for the next major release, which should also remove the warning. + +Until then, `fireEvent` logs a warning when a direct event bubbles from a nested element to the handler of an ancestor that emits it, based on the host element type, e.g. `scroll` from `ScrollView` content to the `ScrollView`'s `onScroll`. Handlers with the same name elsewhere, like an `onLoad` prop of a custom composite component, receive bubbled events without a warning. Only the type of the element with the handler is checked, so a handler further up on an element that doesn't emit the event gets no warning, although it will stop receiving the event too. + +`refresh` is emitted by `RefreshControl`, but the Jest `ScrollView` mock doesn't render the `refreshControl` element. `FlatList` and `SectionList` pass `onRefresh` to the host `ScrollView`, so the rule uses `ScrollView` as the emitting element. + +`contentSizeChange` is not a native `ScrollView` event, so the table above doesn't list it. The `ScrollView` component calls `onContentSizeChange` from the `onLayout` of its content view and passes `onContentSizeChange: null` to the host element. The Jest `ScrollView` mock passes the prop to the host element instead, so the rule uses `ScrollView` as the emitting element. `FlatList` and `SectionList` always set this handler, and tests fire the event on list items, so making it direct will break more tests than other events. ## Sources diff --git a/docs/api/fire-event.md b/docs/api/fire-event.md index 8e550c925..0e69d89d6 100644 --- a/docs/api/fire-event.md +++ b/docs/api/fire-event.md @@ -8,14 +8,25 @@ > Use Fire Event for cases not supported by User Event and for triggering event handlers on composite components. ```ts -function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[]): Promise; +function fireEvent(instance: TestInstance, eventType: string, ...data: unknown[]): Promise; ``` The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. +Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: + +- `layout` and `accessibilityAction` on all elements +- `textLayout` on `Text` +- `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` +- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` +- `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` + +Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. -The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. +The base `fireEvent(instance, eventType, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. This function uses async `act` internally to execute all pending React updates during event handling. @@ -180,7 +191,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Unlike other `fireEvent` calls, layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Unlike `fireEvent(element, 'layout')`, layout events fired with this helper do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 7ef87273a..bfee6e2c3 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -2,7 +2,7 @@ ## Comparison with Fire Event API -Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventName` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. +Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventType` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. In contrast, User Event provides realistic event simulation for user interactions like `press` or `type`. Each interaction will trigger a **sequence of events** corresponding to React Native runtime behavior. These events will be invoked **only on host elements**, and **will automatically receive event data** corresponding to each event. diff --git a/docs/guides/llm-guidelines.md b/docs/guides/llm-guidelines.md index 583ffe453..d09155457 100644 --- a/docs/guides/llm-guidelines.md +++ b/docs/guides/llm-guidelines.md @@ -113,7 +113,7 @@ Use only when `userEvent` doesn't support the event or when you need direct cont | Method | Description | | ---------------------------------------- | --------------------------------------------- | -| `fireEvent(element, eventName, ...data)` | Fire any event by name | +| `fireEvent(element, eventType, ...data)` | Fire any event by name | | `fireEvent.press(element)` | Fire `onPress` only (no `pressIn`/`pressOut`) | | `fireEvent.changeText(element, text)` | Fire `onChangeText` directly | | `fireEvent.scroll(element, eventData)` | Fire `onScroll` with event data | diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 3b9382ae6..3345b2ff7 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import type { TextInputProps } from 'react-native'; import { + FlatList, + Image, + ImageBackground, + Modal, PanResponder, Pressable, ScrollView, @@ -16,6 +20,7 @@ import { import { fireEvent, render, screen } from '../..'; import { configure } from '../../config'; import { _console, logger } from '../../helpers/logger'; +import { getEventHandlerName } from '../handler'; import { nativeState } from '../native-state'; const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; @@ -492,6 +497,7 @@ describe('fireEvent.scroll', () => { }); test('does not use layout size of non-ScrollView element as layoutMeasurement', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onScroll = jest.fn(); await render( @@ -507,6 +513,7 @@ describe('fireEvent.scroll', () => { width: 0, height: 0, }); + warnSpy.mockRestore(); }); }); @@ -566,7 +573,8 @@ describe('fireEvent.layout', () => { expect(onLayout).not.toHaveBeenCalled(); }); - test('does not bubble when fired as generic layout event', async () => { + test('bubbles with a warning when fired as generic layout event', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onLayout = jest.fn(); await render( @@ -577,7 +585,9 @@ describe('fireEvent.layout', () => { await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); - expect(onLayout).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); }); test('does not warn when layout size is saved in native state without onLayout handler', async () => { @@ -600,7 +610,7 @@ describe('fireEvent.layout', () => { expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` - " ▲ No "onLayout" handler found on the element. "layout" events do not bubble to ancestors. + " ▲ No "onLayout" handler found on the element or its ancestors. If this is intentional, you can disable this warning via \`configure({ eventDiagnostics: false })\`. { }); }); +describe('direct events', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + const directEventCases: Array<{ + name: string; + eventName: string; + ui: (handler: jest.Mock) => React.ReactElement; + }> = [ + { + name: 'layout from View content', + eventName: 'layout', + ui: (handler) => ( + + + + ), + }, + { + name: 'accessibilityAction from Pressable content', + eventName: 'accessibilityAction', + ui: (handler) => ( + + Button + + ), + }, + { + name: 'textLayout from nested Text', + eventName: 'textLayout', + ui: (handler) => ( + + Nested + + ), + }, + ...(['scroll', 'selectionChange', 'contentSizeChange'] as const).map((eventName) => ({ + name: `${eventName} from TextInput content`, + eventName, + ui: (handler: jest.Mock) => ( + + Nested + + ), + })), + ...(['loadStart', 'progress', 'load', 'error', 'loadEnd'] as const).map((eventName) => ({ + name: `${eventName} from Image content`, + eventName, + // Image does not accept children, clone it to fire the event on a nested element. + ui: (handler: jest.Mock) => + React.cloneElement( + , + {}, + Nested, + ), + })), + ...( + [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'contentSizeChange', + ] as const + ).map((eventName) => ({ + name: `${eventName} from ScrollView content`, + eventName, + ui: (handler: jest.Mock) => ( + + + + ), + })), + { + name: 'scroll from TextInput to ancestor ScrollView', + eventName: 'scroll', + ui: (handler) => ( + + + + ), + }, + { + name: 'refresh from FlatList item', + eventName: 'refresh', + ui: (handler) => ( + {item}} + refreshing={false} + onRefresh={handler} + /> + ), + }, + { + name: 'contentSizeChange from FlatList item', + eventName: 'contentSizeChange', + ui: (handler) => ( + {item}} + onContentSizeChange={handler} + /> + ), + }, + ...(['requestClose', 'show', 'dismiss', 'orientationChange'] as const).map((eventName) => ({ + name: `${eventName} from Modal content`, + eventName, + ui: (handler: jest.Mock) => ( + + Content + + ), + })), + ]; + + test.each(directEventCases)('bubbles $name with a warning', async ({ eventName, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('target'), eventName); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test.each(directEventCases)( + 'does not warn for $name when fired on the emitting element', + async ({ eventName, ui }) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('emitter'), eventName); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + test('warns when fired with "on" prefixed event name', async () => { + const onMomentumScrollEnd = jest.fn(); + await render( + + + , + ); + + await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd'); + + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test('warns about stopping bubbling in the next major version', async () => { + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(warnSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + " ▲ fireEvent: "scroll" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. Fire it on: + + + " + `); + }); + + test.each([true, false])( + 'warns about bubbling regardless of eventDiagnostics (%s)', + async (eventDiagnostics) => { + configure({ eventDiagnostics }); + await render( + {}}> + + , + ); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('"scroll" does not bubble in React Native'); + }, + ); + + test('warns when handler is on composite component above the emitting element', async () => { + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + // Known gap: only the type of the element with the handler is checked. + test('does not warn when handler is on an element that does not emit the event', async () => { + const onScroll = jest.fn(); + const Screen = (_props: { onScroll: () => void }) => ( + + + + + + ); + await render(); + + await fireEvent.scroll(screen.getByTestId('child')); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling to composite component handler', async () => { + const onLoad = jest.fn(); + const onShow = jest.fn(); + const Card = (_props: { onLoad: () => void; onShow: () => void }) => ( + + Card + + ); + await render(); + + await fireEvent(screen.getByText('Card'), 'load'); + await fireEvent(screen.getByText('Card'), 'show'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onShow).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling to host element that does not emit the event', async () => { + const onLoad = jest.fn(); + await render( + // @ts-expect-error View does not have onLoad prop + + Content + , + ); + + await fireEvent(screen.getByText('Content'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when bubbling load event to ImageBackground handler', async () => { + const onLoad = jest.fn(); + await render( + + Caption + , + ); + + await fireEvent(screen.getByText('Caption'), 'load'); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( onCustomEvent('event data')}> @@ -1281,6 +1579,7 @@ describe('non-editable TextInput', () => { }); test('blocks touch-related events when firing on nested Text child', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); @@ -1315,8 +1614,11 @@ describe('non-editable TextInput', () => { expect(onFocus).not.toHaveBeenCalled(); expect(onChangeText).not.toHaveBeenCalled(); expect(onSubmitEditing).not.toHaveBeenCalled(); - // Layout is a direct event, so it does not bubble to the parent TextInput - expect(onLayout).not.toHaveBeenCalled(); + // Layout is a direct event, but still bubbles to the parent TextInput with a warning + expect(onLayout).toHaveBeenCalledTimes(2); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); }); test.each([ diff --git a/src/events/__tests__/handler.test.tsx b/src/events/__tests__/handler.test.tsx index f1712545e..de3574779 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, normalizeEventName } from '../handler'; +import { getEventHandlerFromProps, normalizeEventType } from '../handler'; test('getEventHandlerFromProps strict mode', async () => { const onPress = jest.fn(); @@ -68,11 +68,11 @@ 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'); +test('normalizeEventType strips the `on*` prefix', () => { + expect(normalizeEventType('onLayout')).toBe('layout'); + expect(normalizeEventType('onChangeText')).toBe('changeText'); + expect(normalizeEventType('layout')).toBe('layout'); + expect(normalizeEventType('changeText')).toBe('changeText'); + expect(normalizeEventType('once')).toBe('once'); + expect(normalizeEventType('on')).toBe('on'); }); diff --git a/src/events/dispatch.ts b/src/events/dispatch.ts index cc8481f7d..400b2c080 100644 --- a/src/events/dispatch.ts +++ b/src/events/dispatch.ts @@ -8,20 +8,20 @@ import { getEventHandlerFromProps } from './handler'; * Basic dispatch event function used by User Event module. * * @param instance instance to trigger event on - * @param eventName name of the event + * @param eventType type of the event * @param event event payload(s) * @returns `true` if a handler was called. */ export async function dispatchEvent( instance: TestInstance, - eventName: string, + eventType: string, ...event: unknown[] ): Promise { if (!isInstanceMounted(instance)) { return false; } - const handler = getEventHandlerFromProps(instance.props, eventName); + const handler = getEventHandlerFromProps(instance.props, eventType); if (!handler) { return false; } diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 9921f66b9..d9a56a000 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -6,28 +6,38 @@ 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 { normalizeEventType } from './handler'; import { nativeState } from './native-state'; -import { findEventHandler } from './propagation'; -import type { EventName, EventProps, LayoutRectangle } from './types'; +import { findEventHandler, type FindEventHandlerOptions } from './propagation'; +import type { EventProps, EventType, LayoutRectangle } from './types'; import { updateNativeStateFromEvent } from './update-native-state'; import { warnAboutUnhandledEvent } from './warnings'; -async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) { +async function fireEvent(instance: TestInstance, eventType: EventType, ...data: unknown[]) { + return await fireEventInternal(instance, { type: eventType, data, bubbles: true }); +} + +type FireEventOptions = FindEventHandlerOptions & { + type: EventType; + data: unknown[]; +}; + +async function fireEventInternal(instance: TestInstance, options: FireEventOptions) { + const { type, data } = options; if (!isInstanceMounted(instance)) { return; } - // `fireEvent` accepts event names with and without the `on*` prefix. + // `fireEvent` accepts event types with and without the `on*` prefix. const hasUpdatedNativeState = updateNativeStateFromEvent( instance, - normalizeEventName(eventName), + normalizeEventType(type), data[0], ); - const { handler, skippedTargets } = findEventHandler(instance, eventName); + const { handler, skippedTargets } = findEventHandler(instance, type, options); if (!handler) { - warnAboutUnhandledEvent(instance, eventName, { skippedTargets, hasUpdatedNativeState }); + warnAboutUnhandledEvent(instance, type, { skippedTargets, hasUpdatedNativeState }); return; } @@ -54,8 +64,14 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => { await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps)); }; +// Unlike `fireEvent(instance, 'layout')`, does not bubble, as React Native delivers layout events +// only to the measured element. fireEvent.layout = async (instance: TestInstance, layout?: Partial) => { - await fireEvent(instance, 'layout', buildLayoutEvent(layout)); + await fireEventInternal(instance, { + type: 'layout', + data: [buildLayoutEvent(layout)], + bubbles: false, + }); }; export { fireEvent }; diff --git a/src/events/handler.ts b/src/events/handler.ts index e7835ec8d..8e0088942 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -7,52 +7,52 @@ export type EventHandlerOptions = { export function getEventHandlerFromProps( props: Record, - eventName: string, + eventType: string, options?: EventHandlerOptions, ): EventHandler | undefined { - const handlerName = getEventHandlerName(eventName); + const handlerName = getEventHandlerName(eventType); if (typeof props[handlerName] === 'function') { return props[handlerName] as EventHandler; } - if (options?.loose && typeof props[eventName] === 'function') { - return props[eventName] as EventHandler; + if (options?.loose && typeof props[eventType] === 'function') { + return props[eventType] as EventHandler; } if (typeof props[`testOnly_${handlerName}`] === 'function') { return props[`testOnly_${handlerName}`] as EventHandler; } - if (options?.loose && typeof props[`testOnly_${eventName}`] === 'function') { - return props[`testOnly_${eventName}`] as EventHandler; + if (options?.loose && typeof props[`testOnly_${eventType}`] === 'function') { + return props[`testOnly_${eventType}`] as EventHandler; } 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. + * Returns the event type without the `on*` prefix, e.g. `onLayout` -> `layout`. + * Note: `fireEvent` accepts event types with and without the prefix, so use this + * before comparing event types. */ -export function normalizeEventName(eventName: string) { - if (hasOnPrefix(eventName)) { - return eventName.charAt(2).toLowerCase() + eventName.slice(3); +export function normalizeEventType(eventType: string) { + if (hasOnPrefix(eventType)) { + return eventType.charAt(2).toLowerCase() + eventType.slice(3); } - return eventName; + return eventType; } -export function getEventHandlerName(eventName: string) { - if (hasOnPrefix(eventName)) { - return eventName; +export function getEventHandlerName(eventType: string) { + if (hasOnPrefix(eventType)) { + return eventType; } - return `on${capitalizeFirstLetter(eventName)}`; + return `on${capitalizeFirstLetter(eventType)}`; } -function hasOnPrefix(eventName: string) { - return /^on[A-Z]/.test(eventName); +function hasOnPrefix(eventType: string) { + return /^on[A-Z]/.test(eventType); } function capitalizeFirstLetter(str: string) { diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index 5d6d220e9..3c5552d32 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -33,10 +33,10 @@ export function isTouchResponder(instance: TestInstance) { const eventsAffectedByPointerEventsProp = new Set(['press']); /** - * Expects event name without the `on*` prefix (see `normalizeEventName`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). */ -export function isEventBlockableByPointerEvents(eventName: string): boolean { - return eventsAffectedByPointerEventsProp.has(eventName); +export function isEventBlockableByPointerEvents(eventType: string): boolean { + return eventsAffectedByPointerEventsProp.has(eventType); } /** @@ -47,21 +47,21 @@ const textInputEventsIgnoringEditableProp = new Set(['contentSizeChange', 'layou /** * 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`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). */ export function isEventEnabled( instance: TestInstance, - eventName: string, + eventType: string, nearestTouchResponder?: TestInstance, ) { if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { return ( isEditableTextInput(nearestTouchResponder) || - textInputEventsIgnoringEditableProp.has(eventName) + textInputEventsIgnoringEditableProp.has(eventType) ); } - if (isEventBlockableByPointerEvents(eventName) && !isPointerEventEnabled(instance)) { + if (isEventBlockableByPointerEvents(eventType) && !isPointerEventEnabled(instance)) { return false; } diff --git a/src/events/propagation.ts b/src/events/propagation.ts index 29613a563..02360e0f5 100644 --- a/src/events/propagation.ts +++ b/src/events/propagation.ts @@ -1,16 +1,75 @@ +import redent from 'redent'; import type { Fiber, TestInstance } from 'test-renderer'; -import { getEventHandlerFromProps, normalizeEventName } from './handler'; +import { formatElement } from '../helpers/format-element'; +import { + isHostImage, + isHostModal, + isHostScrollView, + isHostText, + isHostTextInput, +} from '../helpers/host-component-names'; +import { logger } from '../helpers/logger'; +import { getEventHandlerFromProps, normalizeEventType } from './handler'; import { isEventEnabled, isTouchResponder } from './is-enabled'; import type { EventHandler } from './types'; +const COMMON_DIRECT_EVENTS = ['layout', 'accessibilityAction']; +const TEXT_DIRECT_EVENTS = ['textLayout']; +const TEXT_INPUT_DIRECT_EVENTS = ['scroll', 'selectionChange', 'contentSizeChange']; +const IMAGE_DIRECT_EVENTS = ['loadStart', 'progress', 'load', 'error', 'loadEnd']; +const SCROLL_VIEW_DIRECT_EVENTS = [ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', + 'refresh', + 'contentSizeChange', +]; +const MODAL_DIRECT_EVENTS = ['requestClose', 'show', 'dismiss', 'orientationChange']; + /** - * Direct events are delivered by React Native only to the emitting element and do not bubble. + * Direct events are delivered by React Native only to the host element that emitted them and do + * not bubble. Whether an event is direct depends on the host element type, e.g. `load` is direct + * for `Image` elements, while custom `onLoad` props of composite components still bubble. + * + * `fireEvent` still bubbles these events with a warning, until the next major version. See + * `contributing/native-events.md`. */ -export function isDirectEvent(eventName: string) { - return eventName === 'layout'; +function isDirectEvent(instance: TestInstance, eventType: string) { + if (COMMON_DIRECT_EVENTS.includes(eventType)) { + return true; + } + + if (isHostText(instance)) { + return TEXT_DIRECT_EVENTS.includes(eventType); + } + + if (isHostTextInput(instance)) { + return TEXT_INPUT_DIRECT_EVENTS.includes(eventType); + } + + if (isHostImage(instance)) { + return IMAGE_DIRECT_EVENTS.includes(eventType); + } + + if (isHostScrollView(instance)) { + return SCROLL_VIEW_DIRECT_EVENTS.includes(eventType); + } + + if (isHostModal(instance)) { + return MODAL_DIRECT_EVENTS.includes(eventType); + } + + return false; } +export type FindEventHandlerOptions = { + /** When `false`, only checks the handler of the given element, e.g. for `fireEvent.layout`. */ + bubbles: boolean; +}; + type FindEventHandlerResult = { handler: EventHandler | null; /** @@ -21,39 +80,61 @@ type FindEventHandlerResult = { }; /** - * 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. + * Finds the handler that should receive the event, as `fireEvent` does: events bubble up the + * tree until an enabled handler is found, unless `bubbles` option is `false`. * - * Note: handlers are looked up by the event name as passed, while event rules (direct events, + * Note: handlers are looked up by the event type as passed, while event rules (direct events, * `isEventEnabled`) use the name without the `on*` prefix. */ export function findEventHandler( instance: TestInstance, - eventName: string, + eventType: string, + options: FindEventHandlerOptions, ): FindEventHandlerResult { - if (isDirectEvent(normalizeEventName(eventName))) { - const handler = getEventHandlerFromProps(instance.props, eventName, { loose: true }); + if (!options.bubbles) { + const handler = getEventHandlerFromProps(instance.props, eventType, { loose: true }); return { handler: handler ?? null, skippedTargets: [] }; } - return findBubblingEventHandler(instance, eventName, undefined, []); + const { owner, skippedTargets } = findBubblingHandlerOwner(instance, eventType, undefined, []); + if (!owner) { + return { handler: null, skippedTargets }; + } + + if (owner.instance !== instance && isDirectEvent(owner.instance, normalizeEventType(eventType))) { + logger.warn( + `fireEvent: "${eventType}" does not bubble in React Native. fireEvent will stop bubbling it in the next major version. ` + + `Fire it on:\n\n${redent(formatElement(owner.instance), 2)}`, + ); + } + + return { handler: owner.handler, skippedTargets }; } -function findBubblingEventHandler( +type HandlerOwner = { + handler: EventHandler; + instance: TestInstance; +}; + +type FindHandlerOwnerResult = { + owner: HandlerOwner | null; + skippedTargets: TestInstance[]; +}; + +function findBubblingHandlerOwner( instance: TestInstance, - eventName: string, + eventType: string, nearestTouchResponder: TestInstance | undefined, skippedTargets: TestInstance[], -): FindEventHandlerResult { +): FindHandlerOwnerResult { const touchResponder = isTouchResponder(instance) ? instance : nearestTouchResponder; const handler = - getEventHandlerFromProps(instance.props, eventName, { loose: true }) ?? - findEventHandlerFromFiber(instance.unstable_fiber, eventName); - + getEventHandlerFromProps(instance.props, eventType, { loose: true }) ?? + findEventHandlerFromFiber(instance.unstable_fiber, eventType); if (handler) { - if (isEventEnabled(instance, normalizeEventName(eventName), touchResponder)) { - return { handler, skippedTargets }; + if (isEventEnabled(instance, normalizeEventType(eventType), touchResponder)) { + return { owner: { handler, instance }, skippedTargets }; } // Handlers on the same touch responder report it only once. @@ -64,19 +145,19 @@ function findBubblingEventHandler( } if (instance.parent === null) { - return { handler: null, skippedTargets }; + return { owner: null, skippedTargets }; } - return findBubblingEventHandler(instance.parent, eventName, touchResponder, skippedTargets); + return findBubblingHandlerOwner(instance.parent, eventType, touchResponder, skippedTargets); } -function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { +function findEventHandlerFromFiber(fiber: Fiber | null, eventType: string): EventHandler | null { // Container fibers have memoizedProps set to null if (!fiber?.memoizedProps) { return null; } - const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { + const handler = getEventHandlerFromProps(fiber.memoizedProps, eventType, { loose: true, }); if (handler) { @@ -88,5 +169,5 @@ function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): Even return null; } - return findEventHandlerFromFiber(fiber.return, eventName); + return findEventHandlerFromFiber(fiber.return, eventType); } diff --git a/src/events/types.ts b/src/events/types.ts index bce54d5c6..d01d50f8d 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -13,16 +13,16 @@ 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 { +type EventTypeExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; }; -export type EventName = StringWithAutocomplete< - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor - | EventNameExtractor +export type EventType = StringWithAutocomplete< + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor + | EventTypeExtractor >; /** diff --git a/src/events/update-native-state.ts b/src/events/update-native-state.ts index 236c6a391..c10726ad2 100644 --- a/src/events/update-native-state.ts +++ b/src/events/update-native-state.ts @@ -5,7 +5,7 @@ import { isEditableTextInput } from '../helpers/text-input'; import type { Point, Size } from '../types'; import { nativeState } from './native-state'; -const scrollEventNames = new Set([ +const scrollEventTypes = new Set([ 'scroll', 'scrollBeginDrag', 'scrollEndDrag', @@ -15,21 +15,21 @@ 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`). + * Expects event type without the `on*` prefix (see `normalizeEventType`). * * @returns `true` if native state was updated. */ export function updateNativeStateFromEvent( instance: TestInstance, - eventName: string, + eventType: string, value: unknown, ): boolean { - if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { + if (eventType === 'changeText' && typeof value === 'string' && isEditableTextInput(instance)) { nativeState.valueForInstance.set(instance, value); return true; } - if (scrollEventNames.has(eventName) && isHostScrollView(instance)) { + if (scrollEventTypes.has(eventType) && isHostScrollView(instance)) { const contentOffset = tryGetContentOffset(value); if (contentOffset) { nativeState.contentOffsetForInstance.set(instance, contentOffset); @@ -37,7 +37,7 @@ export function updateNativeStateFromEvent( } } - if (eventName === 'layout') { + if (eventType === 'layout') { const layoutSize = tryGetLayoutSize(value); if (layoutSize) { nativeState.layoutSizeForInstance.set(instance, layoutSize); diff --git a/src/events/warnings.ts b/src/events/warnings.ts index ba1832cbe..34387dbde 100644 --- a/src/events/warnings.ts +++ b/src/events/warnings.ts @@ -7,9 +7,8 @@ import { formatElement, formatJson } from '../helpers/format-element'; import { isHostTextInput } from '../helpers/host-component-names'; import { logger } from '../helpers/logger'; import { isEditableTextInput } from '../helpers/text-input'; -import { getEventHandlerName, normalizeEventName } from './handler'; +import { getEventHandlerName, normalizeEventType } from './handler'; import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; -import { isDirectEvent } from './propagation'; type UnhandledEventInfo = { skippedTargets: TestInstance[]; @@ -28,14 +27,14 @@ export type EventWarning = { */ export function warnAboutUnhandledEvent( instance: TestInstance, - eventName: string, + eventType: string, info: UnhandledEventInfo, ) { if (!getConfig().eventDiagnostics) { return; } - const warning = getUnhandledEventWarning(instance, eventName, info); + const warning = getUnhandledEventWarning(instance, eventType, info); if (warning != null) { logEventWarning(warning); } @@ -93,7 +92,7 @@ export function formatDisabledTargets(targets: TestInstance[]): string { function getUnhandledEventWarning( instance: TestInstance, - eventName: string, + eventType: string, { skippedTargets, hasUpdatedNativeState }: UnhandledEventInfo, ): EventWarning | null { if (skippedTargets.length === 0) { @@ -102,14 +101,7 @@ function getUnhandledEventWarning( return null; } - const handlerName = getEventHandlerName(eventName); - if (isDirectEvent(normalizeEventName(eventName))) { - return { - message: `No "${handlerName}" handler found on the element. "${eventName}" events do not bubble to ancestors.`, - elements: [instance], - }; - } - + const handlerName = getEventHandlerName(eventType); return { message: `No "${handlerName}" handler found on the element or its ancestors.`, elements: [instance], @@ -117,15 +109,15 @@ function getUnhandledEventWarning( } // `pointerEvents` is checked first: it blocks the event even if the element is enabled. - const blocked = isEventBlockableByPointerEvents(normalizeEventName(eventName)) + const blocked = isEventBlockableByPointerEvents(normalizeEventType(eventType)) ? getPointerEventsBlockedTargets(skippedTargets) : null; if (blocked != null) { return { message: blocked.elements.length === 1 - ? `Cannot fire the "${eventName}" event on an element blocked by pointerEvents.` - : `Cannot fire the "${eventName}" event on elements blocked by pointerEvents.`, + ? `Cannot fire the "${eventType}" event on an element blocked by pointerEvents.` + : `Cannot fire the "${eventType}" event on elements blocked by pointerEvents.`, ...blocked, }; } @@ -138,7 +130,7 @@ function getUnhandledEventWarning( } return { - message: `Cannot fire the "${eventName}" event on ${formatDisabledTargets(disabledTargets)}.`, + message: `Cannot fire the "${eventType}" event on ${formatDisabledTargets(disabledTargets)}.`, elements: disabledTargets, }; } diff --git a/website/docs/14.x/docs/api/events/fire-event.mdx b/website/docs/14.x/docs/api/events/fire-event.mdx index b1d51e086..f7d939623 100644 --- a/website/docs/14.x/docs/api/events/fire-event.mdx +++ b/website/docs/14.x/docs/api/events/fire-event.mdx @@ -9,14 +9,25 @@ Use Fire Event for cases not supported by User Event and for triggering event ha ::: ```ts -function fireEvent(instance: TestInstance, eventName: string, ...data: unknown[]): Promise; +function fireEvent(instance: TestInstance, eventType: string, ...data: unknown[]): Promise; ``` The `fireEvent` API triggers event handlers on both host and composite components. It traverses the component tree bottom-up from the passed element to find an enabled event handler named `onXxx` where `xxx` is the event name. +Some events are direct in React Native: they are delivered only to the host element that emitted them. `fireEvent` still bubbles them for backward compatibility, but logs a warning when they bubble from a nested element to the handler of an ancestor that emits them, e.g. `scroll` from `ScrollView` content to the `ScrollView`. They will stop bubbling in the next major version, so fire them on the element that has the handler. These events are: + +- `layout` and `accessibilityAction` on all elements +- `textLayout` on `Text` +- `scroll`, `selectionChange` and `contentSizeChange` on `TextInput` +- `loadStart`, `progress`, `load`, `error` and `loadEnd` on `Image` +- `scroll`, `scrollBeginDrag`, `scrollEndDrag`, `momentumScrollBegin`, `momentumScrollEnd`, `refresh` and `contentSizeChange` on `ScrollView` +- `requestClose`, `show`, `dismiss` and `orientationChange` on `Modal` + +Events with these names bubble without a warning to other handlers, such as an `onLoad` prop of your own composite component. + Unlike User Event, this API does not automatically pass event object to event handler, this is responsibility of the user to construct such object. -The base `fireEvent(instance, eventName, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. +The base `fireEvent(instance, eventType, ...data)` API can pass multiple custom arguments to the handler. Convenience helpers such as `fireEvent.press` and `fireEvent.scroll` are different: they create a default event object and accept one optional object to merge into it. This function uses async `act` internally to execute all pending React updates during event handling. @@ -186,7 +197,7 @@ fireEvent.layout: ( Builds a layout event carrying the given `layout` rectangle and invokes the `onLayout` handler of the given element. Use it to simulate the layout engine measuring an element, e.g. to test components that adapt to a measured size. -Unlike other `fireEvent` calls, layout events do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. +Unlike `fireEvent(element, 'layout')`, layout events fired with this helper do not bubble: React Native delivers them only to the measured element, so the handler is not looked up on parent elements. The `layout` values are merged onto a zeroed rectangle (`{ x: 0, y: 0, width: 0, height: 0 }`), so pass only the fields your component reads. diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx index c5e1886b2..2c7f5fcd6 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -2,7 +2,7 @@ ## Comparison with Fire Event API -Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventName` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. +Fire Event is our original event simulation API. It can invoke **any event handler** declared on **either host or composite elements**. Suppose the element does not have `onEventName` event handler for the passed `eventType` event, or the element is disabled. In that case, Fire Event will traverse up the component tree, looking for an event handler on both host and composite elements along the way. By default, it will **not pass any event data**, but the user might provide it in the last argument. In contrast, User Event provides realistic event simulation for user interactions like `press` or `type`. Each interaction will trigger a **sequence of events** corresponding to React Native runtime behavior. These events will be invoked **only on host elements**, and **will automatically receive event data** corresponding to each event. diff --git a/website/docs/14.x/docs/guides/llm-guidelines.mdx b/website/docs/14.x/docs/guides/llm-guidelines.mdx index 583ffe453..d09155457 100644 --- a/website/docs/14.x/docs/guides/llm-guidelines.mdx +++ b/website/docs/14.x/docs/guides/llm-guidelines.mdx @@ -113,7 +113,7 @@ Use only when `userEvent` doesn't support the event or when you need direct cont | Method | Description | | ---------------------------------------- | --------------------------------------------- | -| `fireEvent(element, eventName, ...data)` | Fire any event by name | +| `fireEvent(element, eventType, ...data)` | Fire any event by name | | `fireEvent.press(element)` | Fire `onPress` only (no `pressIn`/`pressOut`) | | `fireEvent.changeText(element, text)` | Fire `onChangeText` directly | | `fireEvent.scroll(element, eventData)` | Fire `onScroll` with event data |