From d88a6694859bf8307312569e32537c9675d81a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 10:15:08 +0200 Subject: [PATCH 1/6] feat: modern event system typing --- contributing/testing.md | 2 + eslint.config.mjs | 8 ++ modern-types.d.ts | 1 + modern-types.js | 2 + package.json | 4 +- .../modern-types/fire-event.test.tsx | 97 +++++++++++++++++++ src/events/fire-event.ts | 55 +++++++++-- src/events/modern/event-types.ts | 32 ++++-- src/modern-types.ts | 13 +++ tsconfig.json | 6 +- tsconfig.modern-types.json | 5 + website/docs/14.x/docs/api/events/_meta.json | 3 +- .../docs/api/events/modern-event-system.mdx | 16 +++ 13 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 modern-types.d.ts create mode 100644 modern-types.js create mode 100644 src/__tests__/modern-types/fire-event.test.tsx create mode 100644 src/modern-types.ts create mode 100644 tsconfig.modern-types.json create mode 100644 website/docs/14.x/docs/api/events/modern-event-system.mdx diff --git a/contributing/testing.md b/contributing/testing.md index cbbdf0697..1992db051 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -26,3 +26,5 @@ Both projects share the same snapshots. `createEventLogger()` entries print only When a test expects different behavior in the two systems, e.g. events bubbling to a parent, branch on `getConfig().unstable_eventSystem` inside the test instead of skipping it, with a short comment saying which system matches React Native. Name the test after the modern behavior, which aims to match React Native, with the legacy difference in a `(legacy: ...)` suffix, e.g. `does not bubble $name (legacy: bubbles with a warning)`. Prefer ternaries in the expectations (`toHaveBeenCalledTimes(isModern ? 0 : 1)`). For assertions only one system can make, like a legacy warning message, end the test with `if (isModern) return;` before them (`jest/no-conditional-expect` forbids `expect()` inside `if` blocks). Generic `fireEvent(element, eventType, event)` calls that run in both systems pass an event object, e.g. `{ nativeEvent: {} }`, as modern `fireEvent` requires one. `src/events/__tests__/fire-event.test.tsx` runs the public `fireEvent` this way. Tests that can't run in the modern event system at all can call `runInLegacyEventSystem()` from `src/test-utils/event-system.ts` at the top of the file (or in a `describe()`). They then run in the legacy event system in both projects. + +`fireEvent` types follow the legacy signatures. Importing `@testing-library/react-native/modern-types` (`src/modern-types.ts`) switches them to the modern ones, for the whole TypeScript program. So `tsconfig.json` excludes it, and `src/__tests__/modern-types/` type-checks the modern signatures in their own program (`tsconfig.modern-types.json`, run by `yarn typecheck`). There, calls the types reject use `// @ts-expect-error` and also assert that they throw at runtime. diff --git a/eslint.config.mjs b/eslint.config.mjs index 893704448..7fe6a335c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,6 +48,14 @@ export default [ }, }, }, + { + files: ['src/__tests__/modern-types/**/*.{ts,tsx}', 'src/modern-types.ts'], + languageOptions: { + parserOptions: { + project: './tsconfig.modern-types.json', + }, + }, + }, { files: ['**/*.test.{ts,tsx}', 'src/test-utils/**'], plugins: { diff --git a/modern-types.d.ts b/modern-types.d.ts new file mode 100644 index 000000000..ac41e5427 --- /dev/null +++ b/modern-types.d.ts @@ -0,0 +1 @@ +export * from './dist/modern-types'; diff --git a/modern-types.js b/modern-types.js new file mode 100644 index 000000000..9d1a89aa6 --- /dev/null +++ b/modern-types.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-native/modern-types' +module.exports = require('./dist/modern-types'); diff --git a/package.json b/package.json index db2db8305..e82abf834 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:codemods": "node scripts/test-codemods.mjs", "docs:generate": "node scripts/generate-package-docs.mjs", "docs:check": "yarn docs:generate && git diff --exit-code -- docs", - "typecheck": "tsc", + "typecheck": "tsc && tsc -p tsconfig.modern-types.json", "typecheck:react-19_2": "tsc -p tsconfig.react-19_2.json", "lint": "eslint src --cache", "format:check": "oxfmt --check --disable-nested-config .", @@ -52,6 +52,8 @@ "matchers.d.ts", "pure.js", "pure.d.ts", + "modern-types.js", + "modern-types.d.ts", "dont-cleanup-after-each.js", "docs/" ], diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx new file mode 100644 index 000000000..dcfff22e4 --- /dev/null +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -0,0 +1,97 @@ +import '../../modern-types'; + +import * as React from 'react'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; + +import { fireEvent, render, screen } from '../..'; +import { configure } from '../../config'; + +// Type-checked by `tsconfig.modern-types.json`, where `modern-types` switches `fireEvent` to the +// modern signatures. Calls the types reject also throw at runtime. + +beforeEach(() => { + configure({ unstable_eventSystem: 'modern' }); +}); + +const emptyEvent = { nativeEvent: {} }; + +test('accepts known event types, with or without the "on" prefix', async () => { + const onFocus = jest.fn(); + const onLayout = jest.fn(); + await render(); + const view = screen.getByTestId('view'); + + await fireEvent(view, 'focus', emptyEvent); + await fireEvent(view, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 }); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledTimes(1); +}); + +test('accepts custom event types with dispatch', async () => { + const onPageSelected = jest.fn(); + await render(); + + await fireEvent(screen.getByTestId('pager'), 'pageSelected', { + nativeEvent: { position: 1 }, + dispatch: 'direct', + }); + + expect(onPageSelected).toHaveBeenCalledTimes(1); +}); + +test('accepts the helper signatures', async () => { + const onPress = jest.fn(); + const onChangeText = jest.fn(); + const onScroll = jest.fn(); + const onLayout = jest.fn(); + await render( + <> + + + + , + ); + + await fireEvent.press(screen.getByTestId('pressable')); + await fireEvent.changeText(screen.getByTestId('input'), 'Hello'); + await fireEvent.scroll(screen.getByTestId('scroll'), { + nativeEvent: { contentOffset: { y: 200 } }, + }); + await fireEvent.layout(screen.getByTestId('scroll'), { width: 100 }); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onChangeText).toHaveBeenCalledWith('Hello'); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onLayout).toHaveBeenCalledTimes(1); +}); + +test('rejects custom event types without dispatch', async () => { + await render(); + const pager = screen.getByTestId('pager'); + + // @ts-expect-error `dispatch` is required for event types React Native doesn't know + await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type + await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow(); + // @ts-expect-error unsupported `dispatch` value + await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow(); +}); + +test('rejects anything but a single event object', async () => { + await render(); + const view = screen.getByTestId('view'); + + // @ts-expect-error missing event object + await expect(fireEvent(view, 'focus')).rejects.toThrow(); + // @ts-expect-error more than one argument + await expect(fireEvent(view, 'focus', emptyEvent, emptyEvent)).rejects.toThrow(); + // @ts-expect-error not an event object + await expect(fireEvent(view, 'focus', 'event')).rejects.toThrow(); + // @ts-expect-error `nativeEvent` must be an object + await expect(fireEvent(view, 'focus', { nativeEvent: 1 })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent(view, 'focus', { nativeEvent: {}, target: view })).rejects.toThrow(); + // @ts-expect-error unsupported event object key + await expect(fireEvent.press(view, { pageX: 20 })).rejects.toThrow(); +}); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 5a51de7f2..4c234061a 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -1,18 +1,59 @@ import type { TestInstance } from 'test-renderer'; +import type { EventSystem } from '../config'; import { getConfig } from '../config'; import { fireEvent as legacyFireEvent } from './legacy/fire-event'; +import type { CustomEventDispatch, KnownEventType } from './modern/event-types'; import type { FireEventInit } from './modern/fire-event'; import { fireEvent as modernFireEvent } from './modern/fire-event'; import type { EventProps, EventType, LayoutRectangle } from './shared/types'; +/** + * Settings that users augment to change RNTL's types. `@testing-library/react-native/modern-types` + * sets `eventSystem: 'modern'`, so `fireEvent` takes the modern signatures. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Register {} + +/** Event system whose signatures `fireEvent` takes. Defaults to `'legacy'`. */ +type RegisteredEventSystem = Register extends { eventSystem: infer S extends EventSystem } + ? S + : 'legacy'; + +type LegacyFireEvent = { + (instance: TestInstance, eventType: EventType, ...data: unknown[]): Promise; + changeText: (instance: TestInstance, text: string) => Promise; + press: (instance: TestInstance, eventProps?: EventProps) => Promise; + scroll: (instance: TestInstance, eventProps?: EventProps) => Promise; + layout: (instance: TestInstance, layout?: Partial) => Promise; +}; + +/** + * Arguments that modern `fireEvent` accepts at runtime: exactly one event object, and `dispatch` + * for event types React Native doesn't know. + */ +type ModernFireEventArgs = + | [eventType: KnownEventType | `on${Capitalize}`, event: FireEventInit] + | [eventType: string & {}, event: FireEventInit & { dispatch: CustomEventDispatch }]; + +type ModernFireEvent = { + (instance: TestInstance, ...args: ModernFireEventArgs): Promise; + changeText: (instance: TestInstance, text: string) => Promise; + press: (instance: TestInstance, event?: FireEventInit) => Promise; + scroll: (instance: TestInstance, event?: FireEventInit) => Promise; + layout: (instance: TestInstance, layout?: Partial) => Promise; +}; + +export type FireEvent = RegisteredEventSystem extends 'modern' ? ModernFireEvent : LegacyFireEvent; + /** * Public `fireEvent`. Calls the implementation of the event system selected by * `configure({ unstable_eventSystem })`, read on each call, so `configure()` inside a test applies. * - * Types follow the legacy signatures. The modern implementation checks its arguments at runtime. + * Types follow the legacy signatures, or the modern ones when `Register` sets `eventSystem`. The + * modern implementation also checks its arguments at runtime. */ -async function fireEvent( +async function fireEventImpl( instance: TestInstance, eventType: EventType, ...data: unknown[] @@ -24,7 +65,7 @@ async function fireEvent( return await legacyFireEvent(instance, eventType, ...data); } -fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEventImpl.changeText = async (instance: TestInstance, text: string): Promise => { if (isModern()) { return await modernFireEvent.changeText(instance, text); } @@ -32,7 +73,7 @@ fireEvent.changeText = async (instance: TestInstance, text: string): Promise => { +fireEventImpl.press = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { await modernFireEvent.press(instance, eventProps as FireEventInit); return; @@ -41,7 +82,7 @@ fireEvent.press = async (instance: TestInstance, eventProps?: EventProps): Promi await legacyFireEvent.press(instance, eventProps); }; -fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { +fireEventImpl.scroll = async (instance: TestInstance, eventProps?: EventProps): Promise => { if (isModern()) { await modernFireEvent.scroll(instance, eventProps as FireEventInit); return; @@ -50,7 +91,7 @@ fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps): Prom await legacyFireEvent.scroll(instance, eventProps); }; -fireEvent.layout = async ( +fireEventImpl.layout = async ( instance: TestInstance, layout?: Partial, ): Promise => { @@ -66,4 +107,6 @@ function isModern() { return getConfig().unstable_eventSystem === 'modern'; } +const fireEvent = fireEventImpl as FireEvent; + export { fireEvent }; diff --git a/src/events/modern/event-types.ts b/src/events/modern/event-types.ts index ca04bb4d6..1218d6251 100644 --- a/src/events/modern/event-types.ts +++ b/src/events/modern/event-types.ts @@ -9,7 +9,7 @@ import { getEventHandlerName } from '../shared/handler'; * installed `react-native`. */ -export const BUBBLING_EVENT_TYPES: readonly string[] = [ +export const BUBBLING_EVENT_TYPES = [ 'blur', 'change', 'click', @@ -35,12 +35,17 @@ export const BUBBLING_EVENT_TYPES: readonly string[] = [ 'touchEnd', 'touchMove', 'touchStart', -]; +] as const; + +export type BubblingEventType = (typeof BUBBLING_EVENT_TYPES)[number]; /** Bubbling events that run the capture phase as usual, but call only the target when bubbling. */ -export const SKIP_BUBBLING_EVENT_TYPES: readonly string[] = ['pointerEnter', 'pointerLeave']; +export const SKIP_BUBBLING_EVENT_TYPES: readonly BubblingEventType[] = [ + 'pointerEnter', + 'pointerLeave', +]; -export const DIRECT_EVENT_TYPES: readonly string[] = [ +export const DIRECT_EVENT_TYPES = [ 'accessibilityAction', 'accessibilityEscape', 'accessibilityTap', @@ -79,7 +84,9 @@ export const DIRECT_EVENT_TYPES: readonly string[] = [ 'show', // From the inline view config in `TextNativeComponent.js`, which is not exported. 'textLayout', -]; +] as const; + +export type DirectEventType = (typeof DIRECT_EVENT_TYPES)[number]; /** * Pressability callbacks (`onPress`, `onPressIn`, ...) that modern `fireEvent` calls. They are not @@ -92,9 +99,12 @@ export const PRESSABILITY_EVENT_TYPES = ['press', 'pressIn', 'pressOut', 'longPr export type PressabilityEventType = (typeof PRESSABILITY_EVENT_TYPES)[number]; export function isPressabilityEventType(eventType: string): eventType is PressabilityEventType { - return (PRESSABILITY_EVENT_TYPES as readonly string[]).includes(eventType); + return includes(PRESSABILITY_EVENT_TYPES, eventType); } +/** Event types modern `fireEvent` fires without `dispatch`. */ +export type KnownEventType = BubblingEventType | DirectEventType | PressabilityEventType; + /** Direct events whose handler prop doesn't follow the `on` + event type pattern. */ const DIRECT_EVENT_HANDLER_NAMES: Readonly> = { // Added in React Native 0.89. @@ -134,11 +144,11 @@ export type CustomEventDispatch = (typeof CUSTOM_EVENT_DISPATCHES)[number]; * doesn't know it. */ export function getEventTypeConfig(eventType: string): EventTypeConfig | null { - if (BUBBLING_EVENT_TYPES.includes(eventType)) { - return getBubblingEventTypeConfig(eventType, SKIP_BUBBLING_EVENT_TYPES.includes(eventType)); + if (includes(BUBBLING_EVENT_TYPES, eventType)) { + return getBubblingEventTypeConfig(eventType, includes(SKIP_BUBBLING_EVENT_TYPES, eventType)); } - if (DIRECT_EVENT_TYPES.includes(eventType)) { + if (includes(DIRECT_EVENT_TYPES, eventType)) { return { kind: 'direct', handlerName: DIRECT_EVENT_HANDLER_NAMES[eventType] ?? getEventHandlerName(eventType), @@ -177,3 +187,7 @@ function getBubblingEventTypeConfig( skipBubbling, }; } + +function includes(eventTypes: readonly string[], eventType: string): boolean { + return eventTypes.includes(eventType); +} diff --git a/src/modern-types.ts b/src/modern-types.ts new file mode 100644 index 000000000..586f056bb --- /dev/null +++ b/src/modern-types.ts @@ -0,0 +1,13 @@ +/** + * Switches `fireEvent` types to the modern event system. Users import it as + * `@testing-library/react-native/modern-types`, next to `configure({ unstable_eventSystem: 'modern' })`. + * + * Not part of the main tsconfig program, as the augmentation would apply to all tests. + */ +export {}; + +declare module './events/fire-event' { + interface Register { + eventSystem: 'modern'; + } +} diff --git a/tsconfig.json b/tsconfig.json index 746bb86db..10282f768 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,9 @@ "outDir": "dist" }, "include": ["src/**/*"], - "exclude": ["src/__tests__/react-19_2/**/*"] + "exclude": [ + "src/__tests__/react-19_2/**/*", + "src/__tests__/modern-types/**/*", + "src/modern-types.ts" + ] } diff --git a/tsconfig.modern-types.json b/tsconfig.modern-types.json new file mode 100644 index 000000000..d5179bc09 --- /dev/null +++ b/tsconfig.modern-types.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/__tests__/modern-types/**/*"], + "exclude": [] +} diff --git a/website/docs/14.x/docs/api/events/_meta.json b/website/docs/14.x/docs/api/events/_meta.json index 363342aa2..18633f700 100644 --- a/website/docs/14.x/docs/api/events/_meta.json +++ b/website/docs/14.x/docs/api/events/_meta.json @@ -1,4 +1,5 @@ [ { "type": "file", "name": "user-event", "label": "User Event" }, - { "type": "file", "name": "fire-event", "label": "Fire Event" } + { "type": "file", "name": "fire-event", "label": "Fire Event" }, + { "type": "file", "name": "modern-event-system", "label": "Modern Event System" } ] diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx new file mode 100644 index 000000000..6bae0a668 --- /dev/null +++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx @@ -0,0 +1,16 @@ +# Modern Event System + +## How to enable the modern event system + +Add these two lines to your Jest setup file: + +```ts title=jest-setup.ts +import { configure } from '@testing-library/react-native'; +// Type-checks `fireEvent` arguments for the modern event system. +import '@testing-library/react-native/modern-types'; + +// Switches `fireEvent` and `userEvent` to the modern event system. +configure({ unstable_eventSystem: 'modern' }); +``` + +Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScript won't see the import, and you won't get the stricter checks. From 63512422172fd5b94179e85a4748d86d4d6ce659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 10:18:03 +0200 Subject: [PATCH 2/6] better hint for changeText --- .../modern-types/fire-event.test.tsx | 16 +++++++----- src/events/fire-event.ts | 25 ++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx index dcfff22e4..ed5b0b622 100644 --- a/src/__tests__/modern-types/fire-event.test.tsx +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -30,14 +30,16 @@ test('accepts known event types, with or without the "on" prefix', async () => { test('accepts custom event types with dispatch', async () => { const onPageSelected = jest.fn(); - await render(); + const onChangeText = jest.fn(); + await render(); + const native = screen.getByTestId('native'); - await fireEvent(screen.getByTestId('pager'), 'pageSelected', { - nativeEvent: { position: 1 }, - dispatch: 'direct', - }); + await fireEvent(native, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' }); + // A native component's own `changeText` event. + await fireEvent(native, 'changeText', { nativeEvent: { text: 'Hello' }, dispatch: 'direct' }); expect(onPageSelected).toHaveBeenCalledTimes(1); + expect(onChangeText).toHaveBeenCalledTimes(1); }); test('accepts the helper signatures', async () => { @@ -72,8 +74,10 @@ test('rejects custom event types without dispatch', async () => { // @ts-expect-error `dispatch` is required for event types React Native doesn't know await expect(fireEvent(pager, 'pageSelected', emptyEvent)).rejects.toThrow(); - // @ts-expect-error `changeText` is not a native event type + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives await expect(fireEvent(pager, 'changeText', 'Hello')).rejects.toThrow(); + // @ts-expect-error `changeText` is not a native event type, the type error suggests alternatives + await expect(fireEvent(pager, 'onChangeText', emptyEvent)).rejects.toThrow(); // @ts-expect-error unsupported `dispatch` value await expect(fireEvent(pager, 'pageSelected', { dispatch: 'capture' })).rejects.toThrow(); }); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 4c234061a..728a61bf7 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -28,16 +28,29 @@ type LegacyFireEvent = { layout: (instance: TestInstance, layout?: Partial) => Promise; }; +type KnownEventName = KnownEventType | `on${Capitalize}`; + +type CustomFireEventInit = FireEventInit & { dispatch: CustomEventDispatch }; + +/** Shown in the type error of `fireEvent(input, 'changeText', 'Hello')`, as the runtime error. */ +type ChangeTextHint = 'Use fireEvent.changeText() or userEvent.type() instead.'; + /** - * Arguments that modern `fireEvent` accepts at runtime: exactly one event object, and `dispatch` - * for event types React Native doesn't know. + * Event object that modern `fireEvent` accepts at runtime: `dispatch` is required for event types + * React Native doesn't know. Not distributive, so a union with an unknown event type requires it. */ -type ModernFireEventArgs = - | [eventType: KnownEventType | `on${Capitalize}`, event: FireEventInit] - | [eventType: string & {}, event: FireEventInit & { dispatch: CustomEventDispatch }]; +type ModernFireEventInit = [T] extends [KnownEventName] + ? FireEventInit + : [T] extends ['changeText' | 'onChangeText'] + ? ChangeTextHint | CustomFireEventInit + : CustomFireEventInit; type ModernFireEvent = { - (instance: TestInstance, ...args: ModernFireEventArgs): Promise; + ( + instance: TestInstance, + eventType: T, + event: ModernFireEventInit, + ): Promise; changeText: (instance: TestInstance, text: string) => Promise; press: (instance: TestInstance, event?: FireEventInit) => Promise; scroll: (instance: TestInstance, event?: FireEventInit) => Promise; From ef98f6e1973aeabe3a5a4c59eb34ee09e333b899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 10:33:27 +0200 Subject: [PATCH 3/6] warn for "on" prefix in modern fire event --- contributing/event-dispatch.md | 2 + .../modern-types/fire-event.test.tsx | 6 +- src/events/__tests__/fire-event.test.tsx | 243 +++++++++++------- src/events/modern/fire-event-utils.ts | 20 ++ src/events/modern/fire-event.ts | 8 +- src/events/shared/handler.ts | 3 +- .../docs/api/events/modern-event-system.mdx | 4 + 7 files changed, 197 insertions(+), 89 deletions(-) diff --git a/contributing/event-dispatch.md b/contributing/event-dispatch.md index eb5e2842e..9bb951c1c 100644 --- a/contributing/event-dispatch.md +++ b/contributing/event-dispatch.md @@ -20,6 +20,8 @@ In the modern event system, `src/events/modern/pressability.ts` calls the `onPre Modern `fireEvent` throws for event types React Native doesn't know (`ensureEventType()` in `src/events/modern/fire-event-utils.ts`), unless the event object has `dispatch: 'direct' | 'bubbling'`. This is for native events of third-party components, which render as host elements with all their props in Jest, e.g. `fireEvent(pager, 'pageSelected', { nativeEvent: { position: 1 }, dispatch: 'direct' })`. `getCustomEventTypeConfig()` in `src/events/modern/event-types.ts` builds the config codegen would: handler props named after the event type, and no `skipBubbling`, which codegen can't declare. `dispatch` is required, not defaulted, so a legacy call for a JavaScript callback that the Jest mocks put on a host, like `fireEvent(list, 'endReached', ...)`, still throws and points to `UNSAFE_invokeHandler()`. For known event types, `dispatch` overrides React Native's config, as a native component can declare its own event under a built-in name with different semantics, e.g. `react-native-maps` declares `onPress` as a `BubblingEventHandler`. `createEvent()` then uses `getCustomEventTypeConfig()`, and Pressability event types skip Pressability and are dispatched with `dispatchEvent()`. `fireEvent.press()` with `dispatch` also skips the default touch payload. `dispatch` on a `Pressable` therefore calls no callback, as its host has no `onPress` prop. +Modern `fireEvent` takes event types without the `on*` prefix (`press`, not `onPress`), as React Native names them. Like legacy `fireEvent`, it still strips the prefix, but logs a deprecation warning (`warnOnPrefix()` in `src/events/modern/fire-event-utils.ts`), after its checks, so a call that throws doesn't also warn. Support for the prefix will be removed in the next major release. Legacy `fireEvent` accepts both without a warning. + Modern `fireEvent` doesn't log `eventDiagnostics` warnings yet. Only legacy `fireEvent` and `userEvent` do. `src/user-event/` is a separate module on top of the event subsystem. It creates and dispatches native events through the facades `src/events/create-event.ts` and `src/events/dispatch-event.ts`. For a legacy event object, `dispatchEvent()` calls the target's own handler with `invokeEventHandler()` from `src/events/shared/invoke-event-handler.ts`. Other modules are imported directly: legacy touch and responder event builders from `src/events/legacy/builders/common.ts`, and `src/events/modern/` and `src/events/shared/` modules, e.g. `src/user-event/modern/press.ts` (hit testing and responder negotiation from `pressability.ts`, handler lookup from `handler.ts`) and `src/user-event/utils/` (`invokeEventHandler()`, the `SyntheticEvent` type and the warning helpers from `warnings.ts`). diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx index ed5b0b622..2da0bfa1c 100644 --- a/src/__tests__/modern-types/fire-event.test.tsx +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -5,6 +5,7 @@ import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { fireEvent, render, screen } from '../..'; import { configure } from '../../config'; +import { _console } from '../../helpers/logger'; // Type-checked by `tsconfig.modern-types.json`, where `modern-types` switches `fireEvent` to the // modern signatures. Calls the types reject also throw at runtime. @@ -15,7 +16,8 @@ beforeEach(() => { const emptyEvent = { nativeEvent: {} }; -test('accepts known event types, with or without the "on" prefix', async () => { +test('accepts known event types, with or without the deprecated "on" prefix', async () => { + const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onLayout = jest.fn(); await render(); @@ -26,6 +28,8 @@ test('accepts known event types, with or without the "on" prefix', async () => { expect(onFocus).toHaveBeenCalledTimes(1); expect(onLayout).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); }); test('accepts custom event types with dispatch', async () => { diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 6c6655135..809812ddd 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -73,37 +73,21 @@ async function getErrorMessage(promise: Promise) { } } -test('fireEvent accepts event name with or without "on" prefix', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('btn'), 'press', emptyEvent); - expect(onPress).toHaveBeenCalledTimes(1); - - await fireEvent(screen.getByTestId('btn'), 'onPress', emptyEvent); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('fireEvent sets event type without the "on" prefix (legacy: passes the event object as given)', async () => { +test('fireEvent sets event type (legacy: passes the event object as given)', async () => { const onFocus = jest.fn(); await render(); - await fireEvent(screen.getByTestId('input'), 'onFocus', emptyEvent); + await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent); - // Modern matches React Native, where the event type has no "on" prefix. Legacy passes the event - // object as given, without a type. + // Modern matches React Native, where handlers receive an event with a type. Legacy passes the + // event object as given, without a type. expect(onFocus.mock.calls[0][0].type).toBe(isModern() ? 'focus' : undefined); }); test('fireEvent does not call handler props without the "on" prefix (legacy: calls them for unprefixed event names)', async () => { const press = jest.fn(); - const testOnlyPress = jest.fn(); // @ts-expect-error Intentionally passing such props - await render(); - - await fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); - expect(press).not.toHaveBeenCalled(); - expect(testOnlyPress).not.toHaveBeenCalled(); + await render(); // Modern matches React Native, where only Pressability calls press callbacks. Legacy calls any // `press` prop. @@ -111,6 +95,146 @@ test('fireEvent does not call handler props without the "on" prefix (legacy: cal expect(press).toHaveBeenCalledTimes(isModern() ? 0 : 1); }); +describe('"on" prefixed event types', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + function getDeprecationWarnings() { + return warnSpy.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes('"on" prefix')); + } + + test.each([ + ['onPress', 'press', (onPress: jest.Mock) => ], + [ + 'onPressIn', + 'pressIn', + (onPressIn: jest.Mock) => , + ], + ['onFocus', 'focus', (onFocus: jest.Mock) => ], + ['onLayout', 'layout', (onLayout: jest.Mock) => ], + ])( + 'fires "%s" as "%s" with a deprecation warning (legacy: no warning)', + async (eventType, unprefixedType, ui) => { + const handler = jest.fn(); + await render(ui(handler)); + + await fireEvent(screen.getByTestId('target'), eventType, emptyEvent); + + expect(handler).toHaveBeenCalledTimes(1); + // Modern takes event types as React Native names them, and deprecates the "on" prefix. + const warnings = getDeprecationWarnings(); + expect(warnings).toHaveLength(isModern() ? 1 : 0); + const message = + `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` + + 'will be removed in the next major release. ' + + `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`; + expect(warnings.every((warning) => warning.includes(message))).toBe(true); + }, + ); + + test('fires a custom event type with dispatch, with a deprecation warning (legacy: no warning)', async () => { + const onPageSelected = jest.fn(); + await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); + const event = { nativeEvent: { position: 2 }, dispatch: 'direct' }; + + await fireEvent(screen.getByTestId('pager'), 'onPageSelected', event); + + // Modern calls `onPageSelected` for `pageSelected`, as codegen names handler props after event + // types. Legacy passes the event object as given. + expect(onPageSelected.mock.calls).toEqual([[isModern() ? anySyntheticEvent : event]]); + expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); + expect(getDeprecationWarnings()).toHaveLength(isModern() ? 1 : 0); + }); + + test('does not warn when the call throws', async () => { + await render(); + + const error = await getErrorMessage( + fireEvent(screen.getByTestId('input'), 'onChangeText', 'new text'), + ); + + expect(error).toBe( + isModern() + ? `Unable to fire a "changeText" event. React Native doesn't dispatch "changeText" natively. Use fireEvent.changeText() or userEvent.type() instead.` + : undefined, + ); + expect(getDeprecationWarnings()).toHaveLength(0); + }); + + test('accepts event types starting with "on" but without the "on" prefix, without a warning', async () => { + const onOnline = jest.fn(); + await render(React.createElement('RNCNetInfo', { testID: 'net', onOnline })); + + await fireEvent(screen.getByTestId('net'), 'online', { nativeEvent: {}, dispatch: 'direct' }); + + expect(onOnline).toHaveBeenCalledTimes(1); + expect(getDeprecationWarnings()).toHaveLength(0); + }); + + describe('legacy', () => { + runInLegacyEventSystem(); + + test('does not call handler props without the "on" prefix', async () => { + const press = jest.fn(); + const testOnlyPress = jest.fn(); + // @ts-expect-error Intentionally passing such props + await render(); + + await fireEvent(screen.getByTestId('view'), 'onPress', emptyEvent); + + expect(press).not.toHaveBeenCalled(); + expect(testOnlyPress).not.toHaveBeenCalled(); + }); + + test('onChange saves value of TextInput in native state', async () => { + await render(); + const input = screen.getByTestId('input'); + + await fireEvent(input, 'onChange', { nativeEvent: { text: 'new text' } }); + + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }); + + test('onScroll updates native state', async () => { + const onScroll = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + + await fireEvent(scrollView, 'onScroll', verticalScrollEvent); + + expect(onScroll.mock.calls).toEqual([[verticalScrollEvent]]); + expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ x: 0, y: 200 }); + }); + + test.each([ + ['onLayout', (onLayout: jest.Mock) => ], + [ + 'onMomentumScrollEnd', + (onMomentumScrollEnd: jest.Mock) => ( + + ), + ], + ])('%s bubbles with a warning, as unprefixed direct events do', async (eventType, ui) => { + const handler = jest.fn(); + await render(React.cloneElement(ui(handler), {}, )); + + await fireEvent(screen.getByTestId('child'), eventType, emptyEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + test('fireEvent passes event data to handler', async () => { const onPress = jest.fn(); await render(); @@ -959,17 +1083,14 @@ describe('fireEvent.changeText', () => { }); describe('native state', () => { - test.each(['change', 'onChange'])( - '%s event saves value of TextInput in native state', - async (eventType) => { - await render(); - const input = screen.getByTestId('input'); + test('change event saves value of TextInput in native state', async () => { + await render(); + const input = screen.getByTestId('input'); - await fireEvent(input, eventType, { nativeEvent: { text: 'new text' } }); + await fireEvent(input, 'change', { nativeEvent: { text: 'new text' } }); - expect(nativeState.valueForInstance.get(input)).toBe('new text'); - }, - ); + expect(nativeState.valueForInstance.get(input)).toBe('new text'); + }); test('change event without text or on non-editable TextInput does not save value', async () => { await render( @@ -1139,21 +1260,6 @@ 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); - // Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object - // as given. - expect(onScroll.mock.calls).toEqual([[isModern() ? anySyntheticEvent : verticalScrollEvent]]); - expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); - expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({ - x: 0, - y: 200, - }); - }); - test.each([ ['onScroll', 'scroll'], ['onScrollBeginDrag', 'scrollBeginDrag'], @@ -1421,12 +1527,11 @@ describe('fireEvent.layout', () => { ); await fireEvent(screen.getByTestId('child'), 'layout', layoutEvent); - await fireEvent(screen.getByTestId('child'), 'onLayout', layoutEvent); // Modern matches React Native: layout is a direct event, so it doesn't bubble. Legacy still // bubbles it, with a warning. - expect(onLayout).toHaveBeenCalledTimes(isModern() ? 0 : 2); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + expect(onLayout).toHaveBeenCalledTimes(isModern() ? 0 : 1); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); warnSpy.mockRestore(); }); @@ -1683,21 +1788,6 @@ describe('direct events', () => { expect(onParentLayout).not.toHaveBeenCalled(); }); - test('does not bubble when fired with "on" prefixed event name (legacy: bubbles with a warning)', async () => { - const onMomentumScrollEnd = jest.fn(); - await render( - - - , - ); - - await fireEvent(screen.getByTestId('child'), 'onMomentumScrollEnd', emptyEvent); - - // Modern matches React Native: direct events don't bubble. - expect(onMomentumScrollEnd).toHaveBeenCalledTimes(isModern() ? 0 : 1); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); - }); - test('does not bubble scroll (legacy: warns about stopping bubbling in the next major version)', async () => { const onScroll = jest.fn(); await render( @@ -1888,19 +1978,6 @@ describe('custom event types', () => { ); }); - test('accepts a custom event type with the "on" prefix', async () => { - const onPageSelected = jest.fn(); - await render(React.createElement('RNCViewPager', { testID: 'pager', onPageSelected })); - - await fireEvent(screen.getByTestId('pager'), 'onPageSelected', { - nativeEvent: { position: 2 }, - dispatch: 'direct', - }); - - expect(onPageSelected).toHaveBeenCalledTimes(1); - expect(onPageSelected.mock.calls[0][0].nativeEvent).toEqual({ position: 2 }); - }); - test('reaches the user callback through a wrapper that adapts the host prop', async () => { // Shaped like `react-native-pager-view`: the composite passes its own handler to the host, // which then calls the user's prop. @@ -2133,7 +2210,6 @@ describe('Pressability event types', () => { ['pressIn', 'onPressIn'], ['pressOut', 'onPressOut'], ['longPress', 'onLongPress'], - ['onPressIn', 'onPressIn'], ])('"%s" calls only %s of the responder', async (eventType, name) => { const calls: string[] = []; await render( @@ -3231,22 +3307,17 @@ describe('non-editable TextInput', () => { const subject = screen.getByText('Nested Text'); await fireEvent(subject, 'focus', emptyEvent); - await fireEvent(subject, 'onFocus', emptyEvent); const changeTextError = await getErrorMessage(fireEvent.changeText(subject, 'Text')); await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' }, }); - await fireEvent(subject, 'onSubmitEditing', { - nativeEvent: { text: 'Text' }, - }); await fireEvent(subject, 'layout', layoutEvent); - await fireEvent(subject, 'onLayout', layoutEvent); // Legacy matches a device, where a non-editable TextInput can't be focused or submitted. // Modern `fireEvent()` dispatches the events as given, and they bubble from the Text to the // TextInput. - expect(onFocus).toHaveBeenCalledTimes(isModern() ? 2 : 0); - expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 2 : 0); + expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0); + expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0); // Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it. expect(changeTextError).toBe( isModern() @@ -3256,8 +3327,8 @@ describe('non-editable TextInput', () => { expect(onChangeText).not.toHaveBeenCalled(); // Modern matches React Native: layout is a direct event, so it doesn't reach the parent // TextInput. Legacy still bubbles it, with a warning. - expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent], [layoutEvent]]); - expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 2); + expect(onLayout.mock.calls).toEqual(isModern() ? [] : [[layoutEvent]]); + expect(warnSpy).toHaveBeenCalledTimes(isModern() ? 0 : 1); warnSpy.mockRestore(); }); diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 7be96df36..096d87733 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -2,6 +2,8 @@ import type { TestInstance } from 'test-renderer'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; +import { logger } from '../../helpers/logger'; +import { hasOnPrefix, normalizeEventType } from '../shared/handler'; import type { CreateEventInit } from './event'; import { SyntheticEvent } from './event'; import { @@ -21,6 +23,24 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { changeText: 'fireEvent.changeText() or userEvent.type()', }; +/** + * Warns for handler prop names (`onPress`), which modern `fireEvent` strips to event types + * (`press`), as legacy `fireEvent` does. Deprecated, as React Native names event types without the + * prefix. To be removed in the next major release. + */ +export function warnOnPrefix(eventType: string): void { + if (!hasOnPrefix(eventType)) { + return; + } + + const unprefixedType = normalizeEventType(eventType); + logger.warn( + `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` + + 'will be removed in the next major release. ' + + `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`, + ); +} + /** * Throws for event types React Native doesn't know, such as `changeText` or custom prop names, * except Pressability event types (`pressIn`, ...), and event types passed with `dispatch`. diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index 161672a0f..fa86a51fd 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -26,6 +26,7 @@ import { ensureSingleEventArg, validateChangeTextArgs, validateEventInit, + warnOnPrefix, } from './fire-event-utils'; import { dispatchPressabilityEvent, findResponder, getTouchPath } from './pressability'; import { createPressabilityEvent } from './responder-event'; @@ -44,6 +45,9 @@ export type FireEventInit = CreateEventInit; * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the * element that becomes the touch responder instead, as `fireEvent.press()` does. * + * The event type has no `on*` prefix (`press`, not `onPress`), as in React Native's events. Handler + * prop names still work, as in legacy `fireEvent`, but log a warning. + * * Exactly one event object is required, as React Native handlers receive a single event. Event * types React Native doesn't know need `dispatch: 'direct' | 'bubbling'` in the event object, as * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they @@ -56,7 +60,7 @@ export type FireEventInit = CreateEventInit; * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * - * @param eventType with or without the `on*` prefix, e.g. `focus` or `onFocus` + * @param eventType without the `on*` prefix, e.g. `focus`. With it (`onFocus`), it logs a warning. */ export async function fireEvent( instance: TestInstance, @@ -69,6 +73,8 @@ export async function fireEvent( ensureEventType(normalizedType, args[0], fireEvent); ensureSingleEventArg(normalizedType, args, fireEvent); const init = validateEventInit(normalizedType, args[0], fireEvent); + // After the checks, so a call that throws doesn't also warn. + warnOnPrefix(eventType); if (isPressabilityEventType(normalizedType) && init.dispatch == null) { await firePressabilityEvent(instance, normalizedType, init); return; diff --git a/src/events/shared/handler.ts b/src/events/shared/handler.ts index 0e8479e17..67021f936 100644 --- a/src/events/shared/handler.ts +++ b/src/events/shared/handler.ts @@ -70,7 +70,8 @@ function getFunctionProp( return typeof handler === 'function' ? (handler as EventHandler) : undefined; } -function hasOnPrefix(eventType: string) { +/** Whether the event type is a handler prop name, e.g. `onLayout`, not `layout` or `once`. */ +export function hasOnPrefix(eventType: string) { return /^on[A-Z]/.test(eventType); } diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx index 6bae0a668..6aa8e395b 100644 --- a/website/docs/14.x/docs/api/events/modern-event-system.mdx +++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx @@ -14,3 +14,7 @@ configure({ unstable_eventSystem: 'modern' }); ``` Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScript won't see the import, and you won't get the stricter checks. + +## Event types + +Pass event types as React Native names them, without the `on` prefix: `fireEvent(input, 'focus', event)`, not `fireEvent(input, 'onFocus', event)`. Modern `fireEvent` still accepts event types with the prefix, but logs a warning: the prefix is deprecated and support for it will be removed in the next major release. From 5c5ead0d03602cff4faddb74fba61f3b1b869776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:12:24 +0200 Subject: [PATCH 4/6] tweaks --- website/docs/14.x/docs/api/events/modern-event-system.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/14.x/docs/api/events/modern-event-system.mdx b/website/docs/14.x/docs/api/events/modern-event-system.mdx index 6aa8e395b..c9d6070e4 100644 --- a/website/docs/14.x/docs/api/events/modern-event-system.mdx +++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx @@ -17,4 +17,4 @@ Make sure your `tsconfig.json` includes the setup file. If it doesn't, TypeScrip ## Event types -Pass event types as React Native names them, without the `on` prefix: `fireEvent(input, 'focus', event)`, not `fireEvent(input, 'onFocus', event)`. Modern `fireEvent` still accepts event types with the prefix, but logs a warning: the prefix is deprecated and support for it will be removed in the next major release. +Pass event types without the `on` prefix: `'focus'`, not `'onFocus'`. The prefix still works but logs a deprecation warning, and will stop working in the next major release. From 3272e2df0254b259e5dada19c165f42d556ec2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:18:43 +0200 Subject: [PATCH 5/6] cleanup pass --- src/events/__tests__/fire-event.test.tsx | 5 ++--- src/events/fire-event.ts | 1 - src/events/modern/fire-event-utils.ts | 5 ++--- src/events/modern/fire-event.ts | 6 ++---- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 809812ddd..f736dfb58 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -131,13 +131,12 @@ describe('"on" prefixed event types', () => { expect(handler).toHaveBeenCalledTimes(1); // Modern takes event types as React Native names them, and deprecates the "on" prefix. - const warnings = getDeprecationWarnings(); - expect(warnings).toHaveLength(isModern() ? 1 : 0); const message = `Passing event types with the "on" prefix ("${eventType}") to fireEvent is deprecated and ` + 'will be removed in the next major release. ' + `Use "${unprefixedType}" instead, e.g. fireEvent(element, '${unprefixedType}', { nativeEvent: {} }).`; - expect(warnings.every((warning) => warning.includes(message))).toBe(true); + const warning = expect.stringContaining(message); + expect(getDeprecationWarnings()).toEqual(isModern() ? [warning] : []); }, ); diff --git a/src/events/fire-event.ts b/src/events/fire-event.ts index 728a61bf7..2d0dc1b81 100644 --- a/src/events/fire-event.ts +++ b/src/events/fire-event.ts @@ -15,7 +15,6 @@ import type { EventProps, EventType, LayoutRectangle } from './shared/types'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Register {} -/** Event system whose signatures `fireEvent` takes. Defaults to `'legacy'`. */ type RegisteredEventSystem = Register extends { eventSystem: infer S extends EventSystem } ? S : 'legacy'; diff --git a/src/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts index 096d87733..c23e9e368 100644 --- a/src/events/modern/fire-event-utils.ts +++ b/src/events/modern/fire-event-utils.ts @@ -24,9 +24,8 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = { }; /** - * Warns for handler prop names (`onPress`), which modern `fireEvent` strips to event types - * (`press`), as legacy `fireEvent` does. Deprecated, as React Native names event types without the - * prefix. To be removed in the next major release. + * Warns for the deprecated `on` prefix (`onPress`), which modern `fireEvent` still strips, as legacy + * `fireEvent` does. To be removed in the next major release. */ export function warnOnPrefix(eventType: string): void { if (!hasOnPrefix(eventType)) { diff --git a/src/events/modern/fire-event.ts b/src/events/modern/fire-event.ts index fa86a51fd..108983ef9 100644 --- a/src/events/modern/fire-event.ts +++ b/src/events/modern/fire-event.ts @@ -45,9 +45,6 @@ export type FireEventInit = CreateEventInit; * Pressability event types (`press`, `pressIn`, `pressOut`, `longPress`) call the callback of the * element that becomes the touch responder instead, as `fireEvent.press()` does. * - * The event type has no `on*` prefix (`press`, not `onPress`), as in React Native's events. Handler - * prop names still work, as in legacy `fireEvent`, but log a warning. - * * Exactly one event object is required, as React Native handlers receive a single event. Event * types React Native doesn't know need `dispatch: 'direct' | 'bubbling'` in the event object, as * declared by the native component, e.g. a third-party component's `pageSelected`. Without it they @@ -60,7 +57,8 @@ export type FireEventInit = CreateEventInit; * Resolves to nothing, unlike DOM `fireEvent`: React Native has no default actions that * `preventDefault()` could cancel. Handlers can read `event.defaultPrevented` themselves. * - * @param eventType without the `on*` prefix, e.g. `focus`. With it (`onFocus`), it logs a warning. + * @param eventType without the `on*` prefix, as React Native names it, e.g. `focus`. The prefix + * (`onFocus`) is deprecated and logs a warning. */ export async function fireEvent( instance: TestInstance, From 983b130bdf88a0c549a3d500f29708fd303cb80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 15 Oct 2026 12:40:11 +0200 Subject: [PATCH 6/6] fix ci --- src/__tests__/modern-types/fire-event.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/modern-types/fire-event.test.tsx b/src/__tests__/modern-types/fire-event.test.tsx index 2da0bfa1c..dfe56b248 100644 --- a/src/__tests__/modern-types/fire-event.test.tsx +++ b/src/__tests__/modern-types/fire-event.test.tsx @@ -20,11 +20,11 @@ test('accepts known event types, with or without the deprecated "on" prefix', as const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {}); const onFocus = jest.fn(); const onLayout = jest.fn(); - await render(); - const view = screen.getByTestId('view'); + await render(); + const input = screen.getByTestId('input'); - await fireEvent(view, 'focus', emptyEvent); - await fireEvent(view, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 }); + await fireEvent(input, 'focus', emptyEvent); + await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 }); expect(onFocus).toHaveBeenCalledTimes(1); expect(onLayout).toHaveBeenCalledTimes(1);