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/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..dfe56b248
--- /dev/null
+++ b/src/__tests__/modern-types/fire-event.test.tsx
@@ -0,0 +1,105 @@
+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';
+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.
+
+beforeEach(() => {
+ configure({ unstable_eventSystem: 'modern' });
+});
+
+const emptyEvent = { nativeEvent: {} };
+
+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();
+ const input = screen.getByTestId('input');
+
+ await fireEvent(input, 'focus', emptyEvent);
+ await fireEvent(input, 'onLayout', { nativeEvent: { layout: { width: 100 } }, timeStamp: 1 });
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(onLayout).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ warnSpy.mockRestore();
+});
+
+test('accepts custom event types with dispatch', async () => {
+ const onPageSelected = jest.fn();
+ const onChangeText = jest.fn();
+ await render();
+ const native = screen.getByTestId('native');
+
+ 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 () => {
+ 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, 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();
+});
+
+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/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx
index 6c6655135..f736dfb58 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,145 @@ 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 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: {} }).`;
+ const warning = expect.stringContaining(message);
+ expect(getDeprecationWarnings()).toEqual(isModern() ? [warning] : []);
+ },
+ );
+
+ 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 +1082,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 +1259,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 +1526,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 +1787,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 +1977,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 +2209,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 +3306,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 +3326,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/fire-event.ts b/src/events/fire-event.ts
index 5a51de7f2..2d0dc1b81 100644
--- a/src/events/fire-event.ts
+++ b/src/events/fire-event.ts
@@ -1,18 +1,71 @@
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 {}
+
+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;
+};
+
+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.';
+
+/**
+ * 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 ModernFireEventInit = [T] extends [KnownEventName]
+ ? FireEventInit
+ : [T] extends ['changeText' | 'onChangeText']
+ ? ChangeTextHint | CustomFireEventInit
+ : CustomFireEventInit;
+
+type ModernFireEvent = {
+ (
+ 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;
+ 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 +77,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 +85,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 +94,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 +103,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 +119,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/events/modern/fire-event-utils.ts b/src/events/modern/fire-event-utils.ts
index 7be96df36..c23e9e368 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,23 @@ const UNKNOWN_EVENT_TYPE_ALTERNATIVES: Record = {
changeText: 'fireEvent.changeText() or userEvent.type()',
};
+/**
+ * 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)) {
+ 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..108983ef9 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';
@@ -56,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 with or without the `on*` prefix, e.g. `focus` or `onFocus`
+ * @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,
@@ -69,6 +71,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/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..c9d6070e4
--- /dev/null
+++ b/website/docs/14.x/docs/api/events/modern-event-system.mdx
@@ -0,0 +1,20 @@
+# 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.
+
+## Event types
+
+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.