Skip to content

Commit c314d9e

Browse files
refactor: event code organization (#1939)
1 parent 5fb5732 commit c314d9e

39 files changed

Lines changed: 563 additions & 416 deletions

‎contributing/event-dispatch.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,23 @@
22

33
RNTL has two ways to trigger events. Neither goes through React Native's native event system. Both find `on*` props in the rendered tree and call them inside `act()`.
44

5+
Both are built on the shared event subsystem in `src/events/`, which also holds `fireEvent` itself:
6+
7+
| File | Contents |
8+
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
9+
| `fire-event.ts` | Public `fireEvent` API |
10+
| `handler.ts` | Finding the `on*` handler for an event name in props |
11+
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
12+
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
13+
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
14+
| `builders/` | Event payloads, matching what React Native sends on a device |
15+
| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it |
16+
17+
`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`.
18+
519
## `fireEvent`
620

7-
`src/fire-event.ts` calls a single handler for a single event. The work is in finding the right handler:
21+
`src/events/fire-event.ts` is the public API. It calls a single handler for a single event, found with `findEventHandler()` from `src/events/propagation.ts`. The work is in finding the right handler:
822

923
- It starts at the target and moves up the tree until it finds a handler. It also checks props of composite components, not only host elements.
1024
- Direct events (see [Native event propagation](native-events.md)) only check the target.
@@ -20,5 +34,5 @@ Each step uses `dispatchEvent()`, which only calls the target's own handler. It
2034

2135
- To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action.
2236
- Keep `dispatchEvent()` simple.
23-
- Put rules that both need, like `pointerEvents` or `editable`, in shared helpers in `src/helpers/`.
37+
- Put event rules that both need, like the `pointerEvents` and `editable` checks, in `src/events/`. They may build on general helpers from `src/helpers/` (for example `isEditableTextInput`). Code used only by `userEvent`, like delays and scroll steps, stays in `src/user-event/`.
2438
- Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior.

‎contributing/native-events.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
In React Native, some events **bubble** up to parent elements and others are **direct**, meaning only the element that emitted them receives them. `fireEvent` should behave the same way.
44

5-
Today, `fireEvent` treats every event as bubbling except `layout`. The list of direct events lives in `isDirectEvent()` in `src/fire-event.ts`.
5+
Today, `fireEvent` treats every event as bubbling except `layout`. The list of direct events lives in `isDirectEvent()` in `src/events/propagation.ts`.
66

77
## Which events are which
88

‎contributing/native-state.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Native State
22

3-
On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/native-state.ts`.
3+
On a device, some component state lives in native views, not in React. Jest has no native views, so RNTL keeps this state itself in `src/events/native-state.ts`.
44

55
## What is stored
66

@@ -10,7 +10,7 @@ On a device, some component state lives in native views, not in React. Jest has
1010

1111
## Key points
1212

13-
- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make.
13+
- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make. `fireEvent` does it through `updateNativeStateFromEvent()` in `src/events/update-native-state.ts`. Each `userEvent` action writes it directly.
1414
- **Reads.** Helpers read native state, like `getTextInputValue()` in `src/helpers/text-input.ts`. Queries and matchers use those helpers instead of reading native state directly.
1515
- **Props win.** A controlled prop (like `value`) always takes precedence over native state.
1616
- **No reset.** State is stored in `WeakMap`s keyed by host instance. It disappears when the instance is unmounted, so `cleanup()` doesn't need to clear it.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as React from 'react';
2+
import { Text } from 'react-native';
3+
4+
import { render, screen } from '../..';
5+
import { buildTouchEvent } from '../builders/common';
6+
import { dispatchEvent } from '../dispatch';
7+
8+
const TOUCH_EVENT = buildTouchEvent();
9+
10+
test('dispatchEvent calls the target handler', async () => {
11+
const onPress = jest.fn();
12+
await render(<Text testID="text" onPress={onPress} />);
13+
14+
await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
15+
expect(onPress).toHaveBeenCalledTimes(1);
16+
});
17+
18+
test('dispatchEvent does not call the parent host component handler', async () => {
19+
const onPressParent = jest.fn();
20+
await render(
21+
<Text onPress={onPressParent}>
22+
<Text testID="text" />
23+
</Text>,
24+
);
25+
26+
await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
27+
expect(onPressParent).not.toHaveBeenCalled();
28+
});
29+
30+
test('dispatchEvent does not throw when no handler is found', async () => {
31+
await render(
32+
<Text>
33+
<Text testID="text" />
34+
</Text>,
35+
);
36+
37+
await expect(
38+
dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT),
39+
).resolves.not.toThrow();
40+
});
Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
View,
1414
} from 'react-native';
1515

16-
import { fireEvent, render, screen } from '..';
17-
import { _console } from '../helpers/logger';
16+
import { fireEvent, render, screen } from '../..';
17+
import { _console } from '../../helpers/logger';
1818
import { nativeState } from '../native-state';
1919

2020
const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } };
@@ -37,6 +37,20 @@ test('fireEvent accepts event name with or without "on" prefix', async () => {
3737
expect(onPress).toHaveBeenCalledTimes(2);
3838
});
3939

40+
test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => {
41+
const press = jest.fn();
42+
const testOnlyPress = jest.fn();
43+
// @ts-expect-error Intentionally passing such props
44+
await render(<View testID="view" press={press} testOnly_press={testOnlyPress} />);
45+
46+
await fireEvent(screen.getByTestId('view'), 'onPress');
47+
expect(press).not.toHaveBeenCalled();
48+
expect(testOnlyPress).not.toHaveBeenCalled();
49+
50+
await fireEvent(screen.getByTestId('view'), 'press');
51+
expect(press).toHaveBeenCalledTimes(1);
52+
});
53+
4054
test('fireEvent passes event data to handler', async () => {
4155
const onPress = jest.fn();
4256
await render(<Pressable testID="btn" onPress={onPress} />);
@@ -194,6 +208,15 @@ describe('fireEvent.changeText', () => {
194208
expect(nativeState.valueForInstance.get(input)).toBe('new text');
195209
});
196210

211+
test('updates native state when fired with `on*` prefixed name', async () => {
212+
const onChangeText = jest.fn();
213+
await render(<TextInput testID="input" onChangeText={onChangeText} />);
214+
const input = screen.getByTestId('input');
215+
await fireEvent(input, 'onChangeText', 'new text');
216+
expect(onChangeText).toHaveBeenCalledWith('new text');
217+
expect(nativeState.valueForInstance.get(input)).toBe('new text');
218+
});
219+
197220
test('does not fire on non-editable TextInput', async () => {
198221
const onChangeText = jest.fn();
199222
await render(<TextInput testID="input" editable={false} onChangeText={onChangeText} />);
@@ -324,6 +347,18 @@ describe('fireEvent.scroll', () => {
324347
});
325348
});
326349

350+
test('updates native state when fired with `on*` prefixed name', async () => {
351+
const onScroll = jest.fn();
352+
await render(<ScrollView testID="scroll" onScroll={onScroll} />);
353+
const scrollView = screen.getByTestId('scroll');
354+
await fireEvent(scrollView, 'onScroll', verticalScrollEvent);
355+
expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent);
356+
expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({
357+
x: 0,
358+
y: 200,
359+
});
360+
});
361+
327362
test.each([
328363
['onScroll', 'scroll'],
329364
['onScrollBeginDrag', 'scrollBeginDrag'],
Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as React from 'react';
22
import { Text, View } from 'react-native';
33

4-
import { render, screen } from '..';
5-
import { getEventHandlerFromProps } from '../event-handler';
4+
import { render, screen } from '../..';
5+
import { getEventHandlerFromProps, normalizeEventName } from '../handler';
66

7-
test('getEventHandler strict mode', async () => {
7+
test('getEventHandlerFromProps strict mode', async () => {
88
const onPress = jest.fn();
99
const testOnlyOnPress = jest.fn();
1010

@@ -31,15 +31,15 @@ test('getEventHandler strict mode', async () => {
3131
expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(onPress);
3232
});
3333

34-
test('getEventHandler does not treat event names starting with "on" as prefixed', async () => {
34+
test('getEventHandlerFromProps does not treat event names starting with "on" as prefixed', async () => {
3535
const onOnline = jest.fn();
3636
// @ts-expect-error Intentionally passing such props
3737
await render(<View testID="view" onOnline={onOnline} />);
3838

3939
expect(getEventHandlerFromProps(screen.getByTestId('view').props, 'online')).toBe(onOnline);
4040
});
4141

42-
test('getEventHandler loose mode', async () => {
42+
test('getEventHandlerFromProps loose mode', async () => {
4343
const onPress = jest.fn();
4444
const testOnlyOnPress = jest.fn();
4545

@@ -67,3 +67,12 @@ test('getEventHandler loose mode', async () => {
6767
);
6868
expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress);
6969
});
70+
71+
test('normalizeEventName strips the `on*` prefix', () => {
72+
expect(normalizeEventName('onLayout')).toBe('layout');
73+
expect(normalizeEventName('onChangeText')).toBe('changeText');
74+
expect(normalizeEventName('layout')).toBe('layout');
75+
expect(normalizeEventName('changeText')).toBe('changeText');
76+
expect(normalizeEventName('once')).toBe('once');
77+
expect(normalizeEventName('on')).toBe('on');
78+
});
File renamed without changes.
File renamed without changes.

src/event-builder/__tests__/index.test.ts renamed to src/events/builders/__tests__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ test('re-exports all event builders', () => {
1313
expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function);
1414
expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function);
1515
expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function);
16+
expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function);
1617
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { buildTouchEvent } from '../common';
2+
import { mergeEventProps } from '../merge';
3+
4+
test('returns the same event when no props are passed', () => {
5+
const event = buildTouchEvent();
6+
const nativeEvent = { ...event.nativeEvent };
7+
8+
expect(mergeEventProps(event)).toBe(event);
9+
expect(event.nativeEvent).toEqual(nativeEvent);
10+
});
11+
12+
test('deep merges nested objects and keeps other default fields', () => {
13+
const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } });
14+
15+
expect(event.nativeEvent.pageX).toBe(10);
16+
expect(event.nativeEvent.pageY).toBe(20);
17+
expect(event.nativeEvent.locationX).toBe(0);
18+
expect(typeof event.preventDefault).toBe('function');
19+
});
20+
21+
test('replaces arrays and primitive values instead of merging them', () => {
22+
const event = mergeEventProps(buildTouchEvent(), {
23+
nativeEvent: { touches: [{ identifier: 1 }] },
24+
timeStamp: 42,
25+
});
26+
27+
expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]);
28+
expect(event.timeStamp).toBe(42);
29+
});
30+
31+
test('adds props that the event does not have', () => {
32+
const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } });
33+
34+
expect(event).toMatchObject({ custom: { value: 1 } });
35+
});

0 commit comments

Comments
 (0)