Skip to content
Closed
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` drops or moves events a device wouldn't emit from the element (`getEmittingTarget()` in `src/events/modern/emitting-target.ts`), before updating native state, so a dropped `change` leaves it alone. Touch and pointer events (`touchStart`, `pointerDown`, `click`, ...) go to the hit target, found with the same `getTouchPath()` as `press`, so `pointerEvents` moves them to an ancestor or drops them, and `event.target` is the hit target. `display: none` on the element or an ancestor drops them instead of moving them, as a device lays out a hidden view with no frame (iOS doesn't mount it, Android makes it `INVISIBLE`), so no touch can land in its subtree. Text editing events (`focus`, `blur`, `change`, `submitEditing`, `keyPress`, ...) go to the nearest host `TextInput`, as a nested `Text` isn't a native view, and are dropped when it isn't editable. `selectionChange` isn't one of them, as iOS emits it for a non-editable multiline `TextInput`. `change` of a disabled `Switch` is dropped. `disabled` doesn't block touch and pointer events, as on iOS, where only Pressability checks it; on Android, a disabled view is never the hit target, which RNTL doesn't model. Events with `dispatch` keep their target, as the native component declaring them decides when to emit them. The checks aren't in `dispatchEvent()`, which `userEvent` also uses and which stays simple; `userEvent` actions do their own checks. Events are dropped silently, as modern `fireEvent` has no `eventDiagnostics` warnings yet.

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.
Expand Down
328 changes: 283 additions & 45 deletions src/events/__tests__/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PanResponder,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
TouchableHighlight,
Expand Down Expand Up @@ -3241,6 +3242,216 @@ describe('pointerEvents prop', () => {
});
});

describe('touch event hit testing', () => {
test('moves touchStart from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => {
const onParentTouchStart = jest.fn();
const onTouchStart = jest.fn();
await render(
<View testID="parent" onTouchStart={onParentTouchStart}>
<View testID="view" pointerEvents="none" onTouchStart={onTouchStart} />
</View>,
);

await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);

// Modern matches React Native, where hit testing skips the View, so the touch targets its
// parent. Legacy blocks only `press`.
expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe(
isModern() ? screen.getByTestId('parent') : undefined,
);
});

test('drops touchStart when pointerEvents="none" blocks every element (legacy: fires on the View)', async () => {
const onTouchStart = jest.fn();
await render(<View testID="view" pointerEvents="none" onTouchStart={onTouchStart} />);

await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);

expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});

test('drops touchStart on View with pointerEvents="box-none" (legacy: fires on the View)', async () => {
const onTouchStart = jest.fn();
await render(<View testID="view" pointerEvents="box-none" onTouchStart={onTouchStart} />);

await fireEvent(screen.getByTestId('view'), 'touchStart', emptyEvent);

// Modern matches React Native: the View is never the touch target, and has no ancestor.
expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});

test('fires touchStart on child of View with pointerEvents="box-none" (legacy: calls only the child)', async () => {
const onParentTouchStart = jest.fn();
const onTouchStart = jest.fn();
await render(
<View pointerEvents="box-none" onTouchStart={onParentTouchStart}>
<View testID="child" onTouchStart={onTouchStart} />
</View>,
);

await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent);

// Modern matches React Native: the child is the target, and the event bubbles to the parent.
expect(onTouchStart).toHaveBeenCalledTimes(1);
expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onTouchStart.mock.calls[0]?.[0].target).toBe(
isModern() ? screen.getByTestId('child') : undefined,
);
});

test('moves touchStart from child to View with pointerEvents="box-only" (legacy: fires on the child)', async () => {
const onParentTouchStart = jest.fn();
const onTouchStart = jest.fn();
await render(
<View testID="parent" pointerEvents="box-only" onTouchStart={onParentTouchStart}>
<View testID="child" onTouchStart={onTouchStart} />
</View>,
);

await fireEvent(screen.getByTestId('child'), 'touchStart', emptyEvent);

expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(onParentTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onParentTouchStart.mock.calls[0]?.[0].target).toBe(
isModern() ? screen.getByTestId('parent') : undefined,
);
});

test('moves pointerDown from View with pointerEvents="none" to its parent (legacy: fires on the View)', async () => {
const onParentPointerDown = jest.fn();
const onPointerDown = jest.fn();
await render(
<View onPointerDown={onParentPointerDown}>
<View testID="view" pointerEvents="none" onPointerDown={onPointerDown} />
</View>,
);

await fireEvent(screen.getByTestId('view'), 'pointerDown', emptyEvent);

expect(onPointerDown).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(onParentPointerDown).toHaveBeenCalledTimes(isModern() ? 1 : 0);
});

test('fires touch event with dispatch on View with pointerEvents="none"', async () => {
const onTouchStart = jest.fn();
await render(<View testID="view" pointerEvents="none" onTouchStart={onTouchStart} />);

// A native component declaring its own event decides when to emit it.
await fireEvent(screen.getByTestId('view'), 'touchStart', {
nativeEvent: {},
dispatch: 'bubbling',
});

expect(onTouchStart).toHaveBeenCalledTimes(1);
});

test('fires focus inside View with pointerEvents="none"', async () => {
const onFocus = jest.fn();
await render(
<View pointerEvents="none">
<TextInput testID="input" onFocus={onFocus} />
</View>,
);

await fireEvent(screen.getByTestId('input'), 'focus', emptyEvent);

// `pointerEvents` only affects touches. The input can still be focused, e.g. from a keyboard.
expect(onFocus).toHaveBeenCalledTimes(1);
});

test('fires touchStart on disabled Pressable (legacy: blocks it)', async () => {
const onPress = jest.fn();
const onTouchStart = jest.fn();
await render(
<Pressable testID="pressable" disabled onPress={onPress} onTouchStart={onTouchStart} />,
);

await fireEvent(screen.getByTestId('pressable'), 'touchStart', emptyEvent);

// Modern matches iOS, where only Pressability checks `disabled`. Legacy blocks events whose
// touch responder declines the touch.
expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onPress).not.toHaveBeenCalled();
});
});

describe('display: none', () => {
test('does not press inside View with display: none (legacy: presses)', async () => {
const onPress = jest.fn();
await render(
<View style={{ display: 'none' }}>
<Pressable testID="pressable" onPress={onPress} />
</View>,
);

await fireEvent.press(screen.getByTestId('pressable', { includeHiddenElements: true }));

// Modern matches React Native, where a hidden view has no frame, so nothing in it can be
// touched. Legacy doesn't check `display`.
expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});

test('drops touchStart on View with display: none instead of moving it to the parent (legacy: fires on the View)', async () => {
const onParentTouchStart = jest.fn();
const onTouchStart = jest.fn();
await render(
<View onTouchStart={onParentTouchStart}>
<View testID="view" style={{ display: 'none' }} onTouchStart={onTouchStart} />
</View>,
);

await fireEvent(
screen.getByTestId('view', { includeHiddenElements: true }),
'touchStart',
emptyEvent,
);

// Unlike `pointerEvents="none"`, the hidden View covers no area of its parent to touch.
expect(onTouchStart).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(onParentTouchStart).not.toHaveBeenCalled();
});

test('does not press an element that became hidden after it was queried (legacy: presses)', async () => {
const onPress = jest.fn();
function Screen({ hidden }: { hidden: boolean }) {
return (
<View style={hidden ? { display: 'none' } : undefined}>
<Pressable testID="pressable" onPress={onPress} />
</View>
);
}

await render(<Screen hidden={false} />);
const pressable = screen.getByTestId('pressable');
await screen.rerender(<Screen hidden />);

await fireEvent.press(pressable);

expect(onPress).toHaveBeenCalledTimes(isModern() ? 0 : 1);
});
});

describe('disabled Switch', () => {
test('does not dispatch change (legacy: dispatches change)', async () => {
// Legacy calls `Switch`'s `onChange`, whose native value command logs an error in Jest.
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const onChange = jest.fn();
const onValueChange = jest.fn();
await render(
<Switch testID="switch" disabled onChange={onChange} onValueChange={onValueChange} />,
);

await fireEvent(screen.getByTestId('switch'), 'change', { nativeEvent: { value: true } });

// Modern matches a device, where a disabled Switch can't be toggled.
expect(onChange).toHaveBeenCalledTimes(isModern() ? 0 : 1);
expect(onValueChange).toHaveBeenCalledTimes(isModern() ? 0 : 1);
errorSpy.mockRestore();
});
});

describe('non-editable TextInput', () => {
function WrappedTextInput(props: TextInputProps) {
return <TextInput {...props} />;
Expand All @@ -3250,7 +3461,7 @@ describe('non-editable TextInput', () => {
return <WrappedTextInput {...props} />;
}

test('dispatches focus and submitEditing, but not changeText (legacy: blocks touch-related events)', async () => {
test('does not dispatch focus, changeText or submitEditing, but dispatches layout', async () => {
const onFocus = jest.fn();
const onChangeText = jest.fn();
const onSubmitEditing = jest.fn();
Expand All @@ -3273,18 +3484,17 @@ describe('non-editable TextInput', () => {
await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
await fireEvent(input, 'layout', layoutEvent);

// Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
// Modern `fireEvent()` dispatches the event as given, without checking `editable`.
expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
// As on a device, where a non-editable TextInput can't be focused or submitted.
expect(onFocus).not.toHaveBeenCalled();
expect(onChangeText).not.toHaveBeenCalled();
expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onSubmitEditing).not.toHaveBeenCalled();
// Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event object
// as given.
expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
});

test('bubbles focus and submitEditing from nested Text child, but not layout (legacy: blocks touch-related events)', async () => {
test('does not dispatch focus or submitEditing from nested Text child, nor bubble layout (legacy: bubbles layout)', async () => {
const warnSpy = jest.spyOn(_console, 'warn').mockImplementation(() => {});
const onFocus = jest.fn();
const onChangeText = jest.fn();
Expand Down Expand Up @@ -3312,11 +3522,10 @@ describe('non-editable TextInput', () => {
});
await fireEvent(subject, 'layout', 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() ? 1 : 0);
expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
// As on a device, where a non-editable TextInput can't be focused or submitted. Modern emits
// them from the TextInput, as the nested Text isn't a native view.
expect(onFocus).not.toHaveBeenCalled();
expect(onSubmitEditing).not.toHaveBeenCalled();
// Modern `changeText` accepts only a host TextInput. Legacy walks up to it, and skips it.
expect(changeTextError).toBe(
isModern()
Expand All @@ -3334,42 +3543,38 @@ describe('non-editable TextInput', () => {
test.each([
['WrappedTextInput', WrappedTextInput],
['DoubleWrappedTextInput', DoubleWrappedTextInput],
])(
'dispatches focus and submitEditing on %s, but not changeText (legacy: blocks touch-related events)',
async (_, Component) => {
const onFocus = jest.fn();
const onChangeText = jest.fn();
const onSubmitEditing = jest.fn();
const onLayout = jest.fn();
])('does not dispatch focus, changeText or submitEditing on %s', async (_, Component) => {
const onFocus = jest.fn();
const onChangeText = jest.fn();
const onSubmitEditing = jest.fn();
const onLayout = jest.fn();

await render(
<Component
editable={false}
testID="input"
onFocus={onFocus}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
onLayout={onLayout}
/>,
);
await render(
<Component
editable={false}
testID="input"
onFocus={onFocus}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
onLayout={onLayout}
/>,
);

const input = screen.getByTestId('input');
await fireEvent(input, 'focus', emptyEvent);
await fireEvent.changeText(input, 'Text');
await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
await fireEvent(input, 'layout', layoutEvent);

// Legacy matches a device, where a non-editable TextInput can't be focused or submitted.
// Modern `fireEvent()` dispatches the event as given, without checking `editable`.
expect(onFocus).toHaveBeenCalledTimes(isModern() ? 1 : 0);
expect(onChangeText).not.toHaveBeenCalled();
expect(onSubmitEditing).toHaveBeenCalledTimes(isModern() ? 1 : 0);
// Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event
// object as given.
expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
},
);
const input = screen.getByTestId('input');
await fireEvent(input, 'focus', emptyEvent);
await fireEvent.changeText(input, 'Text');
await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } });
await fireEvent(input, 'layout', layoutEvent);

// As on a device, where a non-editable TextInput can't be focused or submitted.
expect(onFocus).not.toHaveBeenCalled();
expect(onChangeText).not.toHaveBeenCalled();
expect(onSubmitEditing).not.toHaveBeenCalled();
// Modern matches React Native, which passes a `SyntheticEvent`. Legacy passes the event
// object as given.
expect(onLayout.mock.calls).toEqual([[isModern() ? anySyntheticEvent : layoutEvent]]);
expect(onLayout.mock.calls[0][0]).toMatchObject(layoutEvent);
});

test('fires layout event', async () => {
const onLayout = jest.fn();
Expand All @@ -3384,6 +3589,39 @@ describe('non-editable TextInput', () => {
await fireEvent(screen.getByTestId('input'), 'scroll', verticalScrollEvent);
expect(onScroll).toHaveBeenCalled();
});

test('fires selectionChange (legacy: blocks it)', async () => {
const onSelectionChange = jest.fn();
await render(
<TextInput testID="input" editable={false} multiline onSelectionChange={onSelectionChange} />,
);

await fireEvent(screen.getByTestId('input'), 'selectionChange', {
nativeEvent: { selection: { start: 0, end: 4 } },
});

// Modern matches iOS, where a non-editable multiline TextInput emits selection changes.
expect(onSelectionChange).toHaveBeenCalledTimes(isModern() ? 1 : 0);
});
});

describe('editable TextInput with nested Text', () => {
test('emits focus from the TextInput (legacy: passes the event object as given)', async () => {
const onFocus = jest.fn();
await render(
<TextInput testID="input" onFocus={onFocus}>
<Text>Nested Text</Text>
</TextInput>,
);

await fireEvent(screen.getByText('Nested Text'), 'focus', emptyEvent);

expect(onFocus).toHaveBeenCalledTimes(1);
// Modern matches React Native, where the nested Text isn't a native view.
expect(onFocus.mock.calls[0][0].target).toEqual(
isModern() ? screen.getByTestId('input') : undefined,
);
});
});

describe('responder system', () => {
Expand Down
Loading
Loading