From ceb434773f92f5353ee878eeb412114e4063adcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Fri, 9 Oct 2026 16:08:25 +0200 Subject: [PATCH 01/17] feat: modern fireEvent.press --- src/events/__tests__/fire-event.test.tsx | 8 +- src/events/fire-event.ts | 12 +- src/events/legacy/index.ts | 2 +- src/events/legacy/is-enabled.ts | 39 +- src/events/legacy/warnings.ts | 3 +- .../modern/__tests__/fire-event.test.tsx | 473 +++++++++++++++++- src/events/modern/dispatch.ts | 2 +- src/events/modern/fire-event.ts | 28 ++ src/events/modern/press.ts | 179 +++++++ src/events/shared/pointer-events.ts | 39 ++ 10 files changed, 737 insertions(+), 48 deletions(-) create mode 100644 src/events/modern/press.ts create mode 100644 src/events/shared/pointer-events.ts diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 07c89479b..f76a03322 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -60,7 +60,7 @@ test('fireEvent.changeText', async () => { ); }); -test('fireEvent.press uses the legacy implementation', async () => { +test('fireEvent.press calls onPress of Pressable with the event of the selected event system', async () => { const onPress = jest.fn(); await render( @@ -68,9 +68,11 @@ test('fireEvent.press uses the legacy implementation', async () => { , ); - expect(await fireEvent.press(screen.getByText('Press me'))).toBeUndefined(); + const result = await fireEvent.press(screen.getByText('Press me')); + const isModern = getConfig().unstable_eventSystem === 'modern'; + expect(result).toBe(isModern ? true : undefined); expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress.mock.calls[0][0]).not.toBeInstanceOf(SyntheticEvent); + expect(onPress.mock.calls[0][0] instanceof SyntheticEvent).toBe(isModern); }); test('fireEvent.scroll and fireEvent.layout pass events of the selected event system', async () => { diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 4b2fc5fe5..7c737a8fe 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -32,10 +32,16 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEvent.press = async ( + instance: TestInstance, + eventProps?: EventProps, +): Promise => { + if (isModern()) { + return await modernFireEvent.press(instance, eventProps as FireEventInit); + } + await legacyFireEvent.press(instance, eventProps); + return undefined; }; fireEvent.scroll = async ( diff --git a/src/events/legacy/index.ts b/src/events/legacy/index.ts index 94a970247..8e5a50541 100644 --- a/src/events/legacy/index.ts +++ b/src/events/legacy/index.ts @@ -1,6 +1,6 @@ export * from './builders/common'; export * from '../shared/handler'; -export { isPointerEventEnabled } from './is-enabled'; +export { isPointerEventEnabled } from '../shared/pointer-events'; export * from '../shared/native-state'; export type { EventWarning } from './warnings'; export { formatDisabledTargets, getPointerEventsBlockedTargets, logEventWarning } from './warnings'; diff --git a/src/events/legacy/is-enabled.ts b/src/events/legacy/is-enabled.ts index 67429aa50..87d30dffe 100644 --- a/src/events/legacy/is-enabled.ts +++ b/src/events/legacy/is-enabled.ts @@ -1,27 +1,8 @@ -import { StyleSheet } from 'react-native'; import type { TestInstance } from 'test-renderer'; import { isHostTextInput } from '../../helpers/host-component-names'; import { isEditableTextInput } from '../../helpers/text-input'; - -/** - * pointerEvents controls whether the View can be the target of touch events. - * 'auto': The View and its children can be the target of touch events. - * 'none': The View is never the target of touch events. - * 'box-none': The View is never the target of touch events but its subviews can be - * 'box-only': The view can be the target of touch events but its subviews cannot be - * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export function isPointerEventEnabled(instance: TestInstance): boolean { - return getPointerEventsBlocker(instance) == null; -} - -/** - * Returns the element whose `pointerEvents` prevents the instance from being the target of - * touch events: the instance itself or one of its ancestors. Returns `null` if nothing blocks it. - */ -export function getPointerEventsBlocker(instance: TestInstance): TestInstance | null { - return findPointerEventsBlocker(instance, false); -} +import { isPointerEventEnabled } from '../shared/pointer-events'; export function isTouchResponder(instance: TestInstance) { return Boolean(instance.props.onStartShouldSetResponder) || isHostTextInput(instance); @@ -73,21 +54,3 @@ export function isEventEnabled( return touchStart === undefined && touchMove === undefined; } - -function findPointerEventsBlocker(instance: TestInstance, isParent: boolean): TestInstance | null { - // Check both props.pointerEvents and props.style.pointerEvents - const pointerEvents = - instance?.props.pointerEvents ?? StyleSheet.flatten(instance?.props.style)?.pointerEvents; - - const parentCondition = isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none'; - - if (pointerEvents === 'none' || parentCondition) { - return instance; - } - - if (!instance.parent) { - return null; - } - - return findPointerEventsBlocker(instance.parent, true); -} diff --git a/src/events/legacy/warnings.ts b/src/events/legacy/warnings.ts index 5a81beade..6c7cb2e48 100644 --- a/src/events/legacy/warnings.ts +++ b/src/events/legacy/warnings.ts @@ -8,7 +8,8 @@ import { isHostTextInput } from '../../helpers/host-component-names'; import { logger } from '../../helpers/logger'; import { isEditableTextInput } from '../../helpers/text-input'; import { getEventHandlerName, normalizeEventType } from '../shared/handler'; -import { getPointerEventsBlocker, isEventBlockableByPointerEvents } from './is-enabled'; +import { getPointerEventsBlocker } from '../shared/pointer-events'; +import { isEventBlockableByPointerEvents } from './is-enabled'; type UnhandledEventInfo = { skippedTargets: TestInstance[]; diff --git a/src/events/modern/__tests__/fire-event.test.tsx b/src/events/modern/__tests__/fire-event.test.tsx index 0970d6093..b8837a3f7 100644 --- a/src/events/modern/__tests__/fire-event.test.tsx +++ b/src/events/modern/__tests__/fire-event.test.tsx @@ -1,5 +1,16 @@ import * as React from 'react'; -import { ScrollView, Text, TextInput, View } from 'react-native'; +import { + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; import type { TestInstance } from 'test-renderer'; import { render, screen } from '../../..'; @@ -619,6 +630,466 @@ describe('fireEvent.changeText', () => { }); }); +describe('fireEvent.press', () => { + const defaultTouchPayload = { + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: expect.any(Number), + touches: [], + }; + + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ['TouchableHighlight', TouchableHighlight], + ['TouchableWithoutFeedback', TouchableWithoutFeedback], + ['TouchableNativeFeedback', TouchableNativeFeedback], + ])('calls onPress of %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions + + Press me + , + ); + + expect(await fireEvent.press(screen.getByTestId('subject'))).toBe(true); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('passes a press event with default touch payload, from the pressed element to the responder', async () => { + let event: SyntheticEvent | undefined; + let currentTarget: unknown; + let eventPhase: number | undefined; + await render( + { + event = e as unknown as SyntheticEvent; + currentTarget = event.currentTarget; + eventPhase = event.eventPhase; + }} + > + Press me + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(event?.type).toBe('press'); + expect(event?.nativeEvent).toEqual(defaultTouchPayload); + expect(event?.target).toBe(screen.getByText('Press me')); + expect(currentTarget).toBe(screen.getByTestId('pressable')); + expect(eventPhase).toBe(event?.BUBBLING_PHASE); + // Reset after the dispatch. + expect(event?.currentTarget).toBeNull(); + }); + + test('deep merges passed nativeEvent onto default touch payload', async () => { + const onPress = jest.fn(); + await render(); + + await fireEvent.press(screen.getByTestId('pressable'), { + nativeEvent: { pageX: 20, locationY: 30 }, + timeStamp: 123, + }); + + const event: SyntheticEvent = onPress.mock.calls[0][0]; + expect(event.nativeEvent).toEqual({ ...defaultTouchPayload, pageX: 20, locationY: 30 }); + expect(event.timeStamp).toBe(123); + }); + + test('validates the event object', async () => { + const onPress = jest.fn(); + await render(); + const pressable = screen.getByTestId('pressable'); + + await expect( + fireEvent.press(pressable, { persist: jest.fn() } as unknown as FireEventInit), + ).rejects.toThrow( + 'Unable to fire a "press" event. Unsupported event object keys: "persist". "persist" is provided by the event.', + ); + expect(onPress).not.toHaveBeenCalled(); + + await fireEvent.press(pressable, undefined); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('calls only onPress, not onPressIn, onPressOut or onLongPress', async () => { + const calls: string[] = []; + await render( + calls.push('onPress')} + onPressIn={() => calls.push('onPressIn')} + onPressOut={() => calls.push('onPressOut')} + onLongPress={() => calls.push('onLongPress')} + />, + ); + + await fireEvent.press(screen.getByTestId('pressable')); + + expect(calls).toEqual(['onPress']); + }); + + test('calls only onPress of the innermost Pressable', async () => { + const onInnerPress = jest.fn(); + const onOuterPress = jest.fn(); + await render( + + + Press me + + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(onInnerPress).toHaveBeenCalledTimes(1); + expect(onOuterPress).not.toHaveBeenCalled(); + }); + + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ])('does not call onPress of disabled %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + + Press me + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(onPress).not.toHaveBeenCalled(); + }); + + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ])('passes the touch past disabled inner to enabled outer %s', async (_, Component) => { + const onInnerPress = jest.fn(); + const onOuterPress = jest.fn(); + await render( + + + Press me + + , + ); + + await fireEvent.press(screen.getByText('Press me')); + + expect(onInnerPress).not.toHaveBeenCalled(); + expect(onOuterPress).toHaveBeenCalledTimes(1); + }); + + test('respects disabled through composite wrappers, ignoring disabled on composites', async () => { + const Button = ({ onPress, isDisabled }: { onPress: () => void; isDisabled: boolean }) => ( + + + {isDisabled ? 'Disabled' : 'Enabled'} + + + ); + const NotForwarding = ({ onPress }: { onPress: () => void; disabled: boolean }) => ( +