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..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,6 +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} from 'react-native/react-private-interface'; // Helpers for common touch event payloads function touchStart(identifier: number = 0): { @@ -132,6 +133,68 @@ 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.skip + : it)( + 'releases the responder when the touched view unmounts before the touch ends', + () => { + const root = Fantom.createRoot(); + const firstRef = React.createRef>(); + const secondRef = React.createRef>(); + const onFirstResponderGrant = jest.fn(); + const onSecondResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onFirstResponderGrant} + />, + ); + }); + + Fantom.dispatchNativeEvent(firstRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onFirstResponderGrant).toHaveBeenCalledTimes(1); + + // 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, + }); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onSecondResponderGrant} + />, + ); + }); + + Fantom.flushAllNativeEvents(); + + // The responder was released, so the next touch is granted + Fantom.dispatchNativeEvent(secondRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onSecondResponderGrant).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(secondRef, '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/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 1ea15520f3dd..53d5d27dc861 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, 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; // Process responder events before normal event dispatch. processResponderEvent(type, internalTarget, payload); try { + // The target is missing 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];