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
18 changes: 16 additions & 2 deletions contributing/event-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@

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()`.

Both are built on the shared event subsystem in `src/events/`, which also holds `fireEvent` itself:

| File | Contents |
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
| `fire-event.ts` | Public `fireEvent` API |
| `handler.ts` | Finding the `on*` handler for an event name in props |
| `propagation.ts` | Bubbling vs direct events, walking up host and composite elements |
| `is-enabled.ts` | Whether a device would deliver the event: `pointerEvents`, `editable`, touch responders |
| `dispatch.ts` | `dispatchEvent()`: calls the target's own handler in `act()`, used by `userEvent` |
| `builders/` | Event payloads, matching what React Native sends on a device |
| `native-state.ts`, `update-native-state.ts` | [Native state](native-state.md) and how `fireEvent` updates it |

`src/user-event/` is a separate module on top of `src/events/` and imports it only through `src/events/index.ts`.

## `fireEvent`

`src/fire-event.ts` calls a single handler for a single event. The work is in finding the right handler:
`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:

- 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.
- Direct events (see [Native event propagation](native-events.md)) only check the target.
Expand All @@ -20,5 +34,5 @@ Each step uses `dispatchEvent()`, which only calls the target's own handler. It

- To change which handler gets a single event, change `fireEvent`. To make an interaction more realistic, change the `userEvent` action.
- Keep `dispatchEvent()` simple.
- Put rules that both need, like `pointerEvents` or `editable`, in shared helpers in `src/helpers/`.
- 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/`.
- Event sequences should match a real device. Check on a device before changing one, and keep the code comments explaining the observed behavior.
2 changes: 1 addition & 1 deletion contributing/native-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.

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

## Which events are which

Expand Down
4 changes: 2 additions & 2 deletions contributing/native-state.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Native State

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`.
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`.

## What is stored

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

## Key points

- **Writes.** `fireEvent` and `userEvent` update native state when they simulate a change that a native view would make.
- **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.
- **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.
- **Props win.** A controlled prop (like `value`) always takes precedence over native state.
- **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.
Expand Down
40 changes: 40 additions & 0 deletions src/events/__tests__/dispatch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from 'react';
import { Text } from 'react-native';

import { render, screen } from '../..';
import { buildTouchEvent } from '../builders/common';
import { dispatchEvent } from '../dispatch';

const TOUCH_EVENT = buildTouchEvent();

test('dispatchEvent calls the target handler', async () => {
const onPress = jest.fn();
await render(<Text testID="text" onPress={onPress} />);

await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
expect(onPress).toHaveBeenCalledTimes(1);
});

test('dispatchEvent does not call the parent host component handler', async () => {
const onPressParent = jest.fn();
await render(
<Text onPress={onPressParent}>
<Text testID="text" />
</Text>,
);

await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT);
expect(onPressParent).not.toHaveBeenCalled();
});

test('dispatchEvent does not throw when no handler is found', async () => {
await render(
<Text>
<Text testID="text" />
</Text>,
);

await expect(
dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT),
).resolves.not.toThrow();
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
View,
} from 'react-native';

import { fireEvent, render, screen } from '..';
import { _console } from '../helpers/logger';
import { fireEvent, render, screen } from '../..';
import { _console } from '../../helpers/logger';
import { nativeState } from '../native-state';

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

test('fireEvent with "on" prefixed name does not call unprefixed handler props', async () => {
const press = jest.fn();
const testOnlyPress = jest.fn();
// @ts-expect-error Intentionally passing such props
await render(<View testID="view" press={press} testOnly_press={testOnlyPress} />);

await fireEvent(screen.getByTestId('view'), 'onPress');
expect(press).not.toHaveBeenCalled();
expect(testOnlyPress).not.toHaveBeenCalled();

await fireEvent(screen.getByTestId('view'), 'press');
expect(press).toHaveBeenCalledTimes(1);
});

test('fireEvent passes event data to handler', async () => {
const onPress = jest.fn();
await render(<Pressable testID="btn" onPress={onPress} />);
Expand Down Expand Up @@ -194,6 +208,15 @@ describe('fireEvent.changeText', () => {
expect(nativeState.valueForInstance.get(input)).toBe('new text');
});

test('updates native state when fired with `on*` prefixed name', async () => {
const onChangeText = jest.fn();
await render(<TextInput testID="input" onChangeText={onChangeText} />);
const input = screen.getByTestId('input');
await fireEvent(input, 'onChangeText', 'new text');
expect(onChangeText).toHaveBeenCalledWith('new text');
expect(nativeState.valueForInstance.get(input)).toBe('new text');
});

test('does not fire on non-editable TextInput', async () => {
const onChangeText = jest.fn();
await render(<TextInput testID="input" editable={false} onChangeText={onChangeText} />);
Expand Down Expand Up @@ -324,6 +347,18 @@ describe('fireEvent.scroll', () => {
});
});

test('updates native state when fired with `on*` prefixed name', async () => {
const onScroll = jest.fn();
await render(<ScrollView testID="scroll" onScroll={onScroll} />);
const scrollView = screen.getByTestId('scroll');
await fireEvent(scrollView, 'onScroll', verticalScrollEvent);
expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent);
expect(nativeState.contentOffsetForInstance.get(scrollView)).toEqual({
x: 0,
y: 200,
});
});

test.each([
['onScroll', 'scroll'],
['onScrollBeginDrag', 'scrollBeginDrag'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react';
import { Text, View } from 'react-native';

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

test('getEventHandler strict mode', async () => {
test('getEventHandlerFromProps strict mode', async () => {
const onPress = jest.fn();
const testOnlyOnPress = jest.fn();

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

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

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

test('getEventHandler loose mode', async () => {
test('getEventHandlerFromProps loose mode', async () => {
const onPress = jest.fn();
const testOnlyOnPress = jest.fn();

Expand Down Expand Up @@ -67,3 +67,12 @@ test('getEventHandler loose mode', async () => {
);
expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress);
});

test('normalizeEventName strips the `on*` prefix', () => {
expect(normalizeEventName('onLayout')).toBe('layout');
expect(normalizeEventName('onChangeText')).toBe('changeText');
expect(normalizeEventName('layout')).toBe('layout');
expect(normalizeEventName('changeText')).toBe('changeText');
expect(normalizeEventName('once')).toBe('once');
expect(normalizeEventName('on')).toBe('on');
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ test('re-exports all event builders', () => {
expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function);
expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function);
expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function);
expect(eventBuilder.mergeEventProps).toBeInstanceOf(Function);
});
35 changes: 35 additions & 0 deletions src/events/builders/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { buildTouchEvent } from '../common';
import { mergeEventProps } from '../merge';

test('returns the same event when no props are passed', () => {
const event = buildTouchEvent();
const nativeEvent = { ...event.nativeEvent };

expect(mergeEventProps(event)).toBe(event);
expect(event.nativeEvent).toEqual(nativeEvent);
});

test('deep merges nested objects and keeps other default fields', () => {
const event = mergeEventProps(buildTouchEvent(), { nativeEvent: { pageX: 10, pageY: 20 } });

expect(event.nativeEvent.pageX).toBe(10);
expect(event.nativeEvent.pageY).toBe(20);
expect(event.nativeEvent.locationX).toBe(0);
expect(typeof event.preventDefault).toBe('function');
});

test('replaces arrays and primitive values instead of merging them', () => {
const event = mergeEventProps(buildTouchEvent(), {
nativeEvent: { touches: [{ identifier: 1 }] },
timeStamp: 42,
});

expect(event.nativeEvent.touches).toEqual([{ identifier: 1 }]);
expect(event.timeStamp).toBe(42);
});

test('adds props that the event does not have', () => {
const event = mergeEventProps(buildTouchEvent(), { custom: { value: 1 } });

expect(event).toMatchObject({ custom: { value: 1 } });
});
File renamed without changes.
11 changes: 1 addition & 10 deletions src/event-builder/common.ts → src/events/builders/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { LayoutRectangle } from '../types';
import { baseSyntheticEvent } from './base';

/**
Expand Down Expand Up @@ -83,16 +84,6 @@ export function buildAccessibilityActionEvent(actionName: string) {
};
}

/**
* Layout rectangle of an element, as measured by the layout engine.
*/
export interface LayoutRectangle {
x: number;
y: number;
width: number;
height: number;
}

/**
* Builds a layout event, as delivered to the `onLayout` handler when an element's
* size or position is measured by the layout engine.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './common';
export * from './merge';
export * from './scroll';
export * from './text';
30 changes: 30 additions & 0 deletions src/events/builders/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { EventProps } from '../types';

/**
* Deep merges custom props into a built event, so tests can override only the fields they need.
* Nested objects are merged, other values (including arrays) are replaced. Mutates and returns
* the passed event.
*/
export function mergeEventProps<T extends object>(event: T, eventProps?: EventProps): T {
if (eventProps) {
mergeInto(event as EventProps, eventProps);
}

return event;
}

function mergeInto(target: EventProps, source: EventProps) {
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = target[key];
if (isObject(sourceValue) && isObject(targetValue)) {
mergeInto(targetValue, sourceValue);
} else {
target[key] = sourceValue;
}
}
}

function isObject(value: unknown): value is EventProps {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Point, Size } from '../types';
import type { Point, Size } from '../../types';
import { baseSyntheticEvent } from './base';

/**
Expand Down
2 changes: 1 addition & 1 deletion src/event-builder/text.ts → src/events/builders/text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Size, TextRange } from '../types';
import type { Size, TextRange } from '../../types';
import { baseSyntheticEvent } from './base';

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { TestInstance } from 'test-renderer';

import { act } from '../../act';
import { getEventHandlerFromProps } from '../../event-handler';
import { isInstanceMounted } from '../../helpers/component-tree';
import { act } from '../act';
import { isInstanceMounted } from '../helpers/component-tree';
import { getEventHandlerFromProps } from './handler';

/**
* Basic dispatch event function used by User Event module.
Expand Down
55 changes: 55 additions & 0 deletions src/events/fire-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { TestInstance } from 'test-renderer';

import { act } from '../act';
import { isInstanceMounted } from '../helpers/component-tree';
import { isHostScrollView } from '../helpers/host-component-names';
import { buildLayoutEvent, buildTouchEvent } from './builders/common';
import { mergeEventProps } from './builders/merge';
import { buildScrollEvent } from './builders/scroll';
import { normalizeEventName } from './handler';
import { nativeState } from './native-state';
import { findEventHandler } from './propagation';
import type { EventName, EventProps, LayoutRectangle } from './types';
import { updateNativeStateFromEvent } from './update-native-state';

async function fireEvent(instance: TestInstance, eventName: EventName, ...data: unknown[]) {
if (!isInstanceMounted(instance)) {
return;
}

// `fireEvent` accepts event names with and without the `on*` prefix.
updateNativeStateFromEvent(instance, normalizeEventName(eventName), data[0]);

const handler = findEventHandler(instance, eventName);
if (!handler) {
return;
}

let returnValue;
await act(() => {
returnValue = handler(...data);
});

return returnValue;
}

fireEvent.changeText = async (instance: TestInstance, text: string) =>
await fireEvent(instance, 'changeText', text);

fireEvent.press = async (instance: TestInstance, eventProps?: EventProps) => {
await fireEvent(instance, 'press', mergeEventProps(buildTouchEvent(), eventProps));
};

fireEvent.scroll = async (instance: TestInstance, eventProps?: EventProps) => {
const layoutMeasurement = isHostScrollView(instance)
? nativeState.layoutSizeForInstance.get(instance)
: undefined;
const event = buildScrollEvent(undefined, { layoutMeasurement });
await fireEvent(instance, 'scroll', mergeEventProps(event, eventProps));
};

fireEvent.layout = async (instance: TestInstance, layout?: Partial<LayoutRectangle>) => {
await fireEvent(instance, 'layout', buildLayoutEvent(layout));
};

export { fireEvent };
Loading
Loading