Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions modern-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/modern-types';
2 changes: 2 additions & 0 deletions modern-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// makes it so people can import from '@testing-library/react-native/modern-types'
module.exports = require('./dist/modern-types');
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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/"
],
Expand Down
105 changes: 105 additions & 0 deletions src/__tests__/modern-types/fire-event.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TextInput testID="input" onFocus={onFocus} onLayout={onLayout} />);
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(<View testID="native" {...{ onPageSelected, onChangeText }} />);
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(
<>
<Pressable testID="pressable" onPress={onPress} />
<TextInput testID="input" onChangeText={onChangeText} />
<ScrollView testID="scroll" onScroll={onScroll} onLayout={onLayout} />
</>,
);

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(<View testID="pager" />);
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(<View testID="view" />);
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();
});
Loading
Loading