From 879667a59b98d325cd1edae2c6d80a43de98fa41 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:58:53 +0000 Subject: [PATCH] fix: deliver raw touch events past a declining touch responder --- CHANGELOG.md | 5 ++++ src/events/__tests__/fire-event.test.tsx | 33 ++++++++++++++++++++++++ src/events/is-enabled.ts | 10 +++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 998e5bdd3..df52c0858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ with v14. - `toBeDisabled()` and `*ByRole` queries prefer `disabled` over `aria-disabled` and `accessibilityState.disabled` on `Text`, as React Native does. A `Text` with `disabled={false}` and `aria-disabled` is enabled. +- `fireEvent` delivers raw touch events (`touchStart`, `touchMove`, `touchEnd`, `touchCancel`) + to a handler even when the nearest touch responder declines the touch, as a device does. Before, + a child with `onStartShouldSetResponder` returning `false` stopped these events from reaching + the `onTouchStart` of its ancestors. Responder-driven events like `press` still respect the + responder. ## 14.1.0 diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx index 67a40615e..e8b108813 100644 --- a/src/events/__tests__/fire-event.test.tsx +++ b/src/events/__tests__/fire-event.test.tsx @@ -1812,4 +1812,37 @@ describe('responder system', () => { }); expect(onDrag).toHaveBeenCalled(); }); + + test.each([ + ['touchStart', 'onTouchStart'], + ['touchMove', 'onTouchMove'], + ['touchEnd', 'onTouchEnd'], + ['touchCancel', 'onTouchCancel'], + ])('fires %s on parent when child declines the responder', async (eventName, handlerName) => { + const onTouch = jest.fn(); + await render( + + false} + onMoveShouldSetResponder={() => false} + /> + , + ); + + await fireEvent(screen.getByTestId('child'), eventName); + + expect(onTouch).toHaveBeenCalledTimes(1); + }); + + test('fires touchStart on element that declines the responder', async () => { + const onTouchStart = jest.fn(); + await render( + false} />, + ); + + await fireEvent(screen.getByTestId('view'), 'touchStart'); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/events/is-enabled.ts b/src/events/is-enabled.ts index 0aef969a9..b593fcb58 100644 --- a/src/events/is-enabled.ts +++ b/src/events/is-enabled.ts @@ -44,6 +44,12 @@ export function isEventBlockableByPointerEvents(eventType: string): boolean { */ const TEXT_INPUT_EVENTS_IGNORING_EDITABLE = ['contentSizeChange', 'layout', 'scroll']; +/** + * List of raw touch events. A device delivers them to every ancestor of the touched view, whether + * or not a touch responder accepts the touch. + */ +const TOUCH_EVENTS_IGNORING_RESPONDER = ['touchStart', 'touchMove', 'touchEnd', 'touchCancel']; + /** * Checks whether a device would deliver the event to the instance, taking into account * `pointerEvents`, non-editable `TextInput` and touch responders that decline the touch. @@ -65,6 +71,10 @@ export function isEventEnabled( return false; } + if (TOUCH_EVENTS_IGNORING_RESPONDER.includes(eventType)) { + return true; + } + const touchStart = nearestTouchResponder?.props.onStartShouldSetResponder?.(); const touchMove = nearestTouchResponder?.props.onMoveShouldSetResponder?.(); if (touchStart || touchMove) {