From 2d382197e4348c142f4be1ce3977f1c13dc487be Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 27 Oct 2026 06:05:10 +0500 Subject: [PATCH 1/2] Process responder events for touches whose target unmounted --- .../__tests__/ResponderEventTarget-itest.js | 52 +++++++++++++++++++ .../renderer/events/dispatchNativeEvent.js | 10 +++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js index 605d90317e3f..7efa3dbc4cfd 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js @@ -14,6 +14,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; +import { + ReactNativeFeatureFlags, + dispatchNativeEvent, +} from 'react-native/react-private-interface'; // Helpers for common touch event payloads function touchStart(identifier: number = 0): { @@ -132,6 +136,54 @@ describe('Responder System', () => { expect(onResponderRelease).toHaveBeenCalledTimes(1); }); + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + ? it + : it.skip)( + 'dispatches responder release on touch end without a target', + () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + const onResponderRelease = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onResponderGrant} + onResponderRelease={onResponderRelease} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(1); + + // The target is null when the touched view unmounted during the touch. + dispatchNativeEvent(null, 'topTouchEnd', { + touches: [], + changedTouches: [{identifier: 0, pageX: 0, pageY: 0, timestamp: 200}], + }); + + expect(onResponderRelease).toHaveBeenCalledTimes(1); + + // Responder was cleared, so the next touch is granted again + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(2); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }, + ); + it('dispatches responderTerminate on touch cancel', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index 1ea15520f3dd..6641ffec87fa 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -37,17 +37,23 @@ import { * enableNativeEventTargetEventDispatching is enabled. */ export default function dispatchNativeEvent( - target: EventTarget, + target: EventTarget | null, type: string, payload: {[string]: unknown}, ): void { // $FlowFixMe[incompatible-type] The global is backed by this implementation. - const internalTarget = target as InternalEventTarget; + const internalTarget = target as InternalEventTarget | null; // Process responder events before normal event dispatch. processResponderEvent(type, internalTarget, payload); try { + // The target is null when its view unmounted during the touch, so only the + // responder step above applies. + if (internalTarget == null) { + return; + } + // Normal EventTarget dispatch const bubbleConfig = customBubblingEventTypes[type]; const directConfig = customDirectEventTypes[type]; From 6208d24949efe5733c92ef3174265ad68aa4ff96 Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 27 Oct 2026 16:55:21 +0500 Subject: [PATCH 2/2] Test the unmounted touch target through Fantom and accept an undefined target --- .../__tests__/ResponderEventTarget-itest.js | 59 +++++++++++-------- .../renderer/events/ReactNativeResponder.js | 2 +- .../renderer/events/dispatchNativeEvent.js | 8 +-- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js index 7efa3dbc4cfd..5016c87f635e 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js @@ -14,10 +14,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; -import { - ReactNativeFeatureFlags, - dispatchNativeEvent, -} from 'react-native/react-private-interface'; +import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; // Helpers for common touch event payloads function touchStart(identifier: number = 0): { @@ -136,49 +133,63 @@ describe('Responder System', () => { expect(onResponderRelease).toHaveBeenCalledTimes(1); }); + // TODO(react/react#38006): run this with the flag on too once the synced + // renderer stops dropping events whose target is null. (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? it - : it.skip)( - 'dispatches responder release on touch end without a target', + ? it.skip + : it)( + 'releases the responder when the touched view unmounts before the touch ends', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - const onResponderGrant = jest.fn(); - const onResponderRelease = jest.fn(); + const firstRef = React.createRef>(); + const secondRef = React.createRef>(); + const onFirstResponderGrant = jest.fn(); + const onSecondResponderGrant = jest.fn(); Fantom.runTask(() => { root.render( true} - onResponderGrant={onResponderGrant} - onResponderRelease={onResponderRelease} + onResponderGrant={onFirstResponderGrant} />, ); }); - Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + Fantom.dispatchNativeEvent(firstRef, 'onTouchStart', touchStart(), { category: Fantom.NativeEventCategory.Discrete, }); - expect(onResponderGrant).toHaveBeenCalledTimes(1); + expect(onFirstResponderGrant).toHaveBeenCalledTimes(1); - // The target is null when the touched view unmounted during the touch. - dispatchNativeEvent(null, 'topTouchEnd', { - touches: [], - changedTouches: [{identifier: 0, pageX: 0, pageY: 0, timestamp: 200}], + // Queue the touch end without flushing it, then unmount the view, so the + // event reaches JS without a target. + Fantom.enqueueNativeEvent(firstRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, }); - expect(onResponderRelease).toHaveBeenCalledTimes(1); + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onSecondResponderGrant} + />, + ); + }); - // Responder was cleared, so the next touch is granted again - Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + Fantom.flushAllNativeEvents(); + + // The responder was released, so the next touch is granted + Fantom.dispatchNativeEvent(secondRef, 'onTouchStart', touchStart(), { category: Fantom.NativeEventCategory.Discrete, }); - expect(onResponderGrant).toHaveBeenCalledTimes(2); + expect(onSecondResponderGrant).toHaveBeenCalledTimes(1); - Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + Fantom.dispatchNativeEvent(secondRef, 'onTouchEnd', touchEnd(), { category: Fantom.NativeEventCategory.Discrete, }); }, diff --git a/packages/react-native/src/private/renderer/events/ReactNativeResponder.js b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js index 1323f08c4a0a..b0db7a7cbd02 100644 --- a/packages/react-native/src/private/renderer/events/ReactNativeResponder.js +++ b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js @@ -591,7 +591,7 @@ function isResponderRelevantTopLevelType(topLevelType: string): boolean { */ export function processResponderEvent( topLevelType: string, - eventTarget: EventTarget | null, + eventTarget: ?EventTarget, nativeEvent: {[string]: unknown}, ): void { // Fast path: if this event isn't one the responder system cares about and diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index 6641ffec87fa..53d5d27dc861 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -37,19 +37,19 @@ import { * enableNativeEventTargetEventDispatching is enabled. */ export default function dispatchNativeEvent( - target: EventTarget | null, + target: ?EventTarget, type: string, payload: {[string]: unknown}, ): void { // $FlowFixMe[incompatible-type] The global is backed by this implementation. - const internalTarget = target as InternalEventTarget | null; + const internalTarget = target as ?InternalEventTarget; // Process responder events before normal event dispatch. processResponderEvent(type, internalTarget, payload); try { - // The target is null when its view unmounted during the touch, so only the - // responder step above applies. + // The target is missing when its view unmounted during the touch, so only + // the responder step above applies. if (internalTarget == null) { return; }