Skip to content

Commit f754dbd

Browse files
lazergmeta-codesync[bot]
authored andcommitted
Process responder events for touches whose target unmounted (#59522)
Summary: With `enableNativeEventTargetEventDispatching` on, a touch that ends after its view unmounted never reaches the responder, so the old responder sticks around and `trackedTouchCount` stays one too high, which drops the next tap. `processResponderEvent` already handles a null target, but `dispatchNativeEvent` only accepts a non-null one and would crash in `dispatchTrustedEvent` if it got null. It now takes `EventTarget | null` and returns right after the responder step when there is no target. The renderer's `dispatchEvent` (`ReactFabricEventEmitter.js` in react/react) still skips the call for a null target, so that guard has to go too before the bug is fully fixed, and this change needs to land first. Fixes #59519 ## Changelog: [GENERAL] [FIXED] - Fix the responder not being released when a touch ends after its view unmounted Pull Request resolved: #59522 Test Plan: Added a Fantom test to `ResponderEventTarget-itest.js` that grants the responder, sends `topTouchEnd` with a null target through `dispatchNativeEvent`, and checks that the release fires and the next touch is granted again. I couldn't build the Fantom runner locally, so I also ran the null-target case as a throwaway Jest test: on `main` it throws `TypeError: Cannot read properties of null` from `dispatchTrustedEvent`, and it passes with this change. `flow check`, eslint and prettier are clean. Reviewed By: javache Differential Revision: D127796330 Pulled By: rubennorte fbshipit-source-id: 3c3547998b3b079a540e8cdf8e904ee95a855515
1 parent 43a49f7 commit f754dbd

3 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
1414
import * as Fantom from '@react-native/fantom';
1515
import * as React from 'react';
1616
import {View} from 'react-native';
17+
import {ReactNativeFeatureFlags} from 'react-native/react-private-interface';
1718

1819
// Helpers for common touch event payloads
1920
function touchStart(identifier: number = 0): {
@@ -132,6 +133,68 @@ describe('Responder System', () => {
132133
expect(onResponderRelease).toHaveBeenCalledTimes(1);
133134
});
134135

136+
// TODO(react/react#38006): run this with the flag on too once the synced
137+
// renderer stops dropping events whose target is null.
138+
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
139+
? it.skip
140+
: it)(
141+
'releases the responder when the touched view unmounts before the touch ends',
142+
() => {
143+
const root = Fantom.createRoot();
144+
const firstRef = React.createRef<React.ElementRef<typeof View>>();
145+
const secondRef = React.createRef<React.ElementRef<typeof View>>();
146+
const onFirstResponderGrant = jest.fn();
147+
const onSecondResponderGrant = jest.fn();
148+
149+
Fantom.runTask(() => {
150+
root.render(
151+
<View
152+
key="first"
153+
ref={firstRef}
154+
onStartShouldSetResponder={() => true}
155+
onResponderGrant={onFirstResponderGrant}
156+
/>,
157+
);
158+
});
159+
160+
Fantom.dispatchNativeEvent(firstRef, 'onTouchStart', touchStart(), {
161+
category: Fantom.NativeEventCategory.Discrete,
162+
});
163+
164+
expect(onFirstResponderGrant).toHaveBeenCalledTimes(1);
165+
166+
// Queue the touch end without flushing it, then unmount the view, so the
167+
// event reaches JS without a target.
168+
Fantom.enqueueNativeEvent(firstRef, 'onTouchEnd', touchEnd(), {
169+
category: Fantom.NativeEventCategory.Discrete,
170+
});
171+
172+
Fantom.runTask(() => {
173+
root.render(
174+
<View
175+
key="second"
176+
ref={secondRef}
177+
onStartShouldSetResponder={() => true}
178+
onResponderGrant={onSecondResponderGrant}
179+
/>,
180+
);
181+
});
182+
183+
Fantom.flushAllNativeEvents();
184+
185+
// The responder was released, so the next touch is granted
186+
Fantom.dispatchNativeEvent(secondRef, 'onTouchStart', touchStart(), {
187+
category: Fantom.NativeEventCategory.Discrete,
188+
});
189+
190+
expect(onSecondResponderGrant).toHaveBeenCalledTimes(1);
191+
192+
Fantom.dispatchNativeEvent(secondRef, 'onTouchEnd', touchEnd(), {
193+
category: Fantom.NativeEventCategory.Discrete,
194+
});
195+
},
196+
);
197+
135198
it('dispatches responderTerminate on touch cancel', () => {
136199
const root = Fantom.createRoot();
137200
const ref = React.createRef<React.ElementRef<typeof View>>();

‎packages/react-native/src/private/renderer/events/ReactNativeResponder.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ function isResponderRelevantTopLevelType(topLevelType: string): boolean {
591591
*/
592592
export function processResponderEvent(
593593
topLevelType: string,
594-
eventTarget: EventTarget | null,
594+
eventTarget: ?EventTarget,
595595
nativeEvent: {[string]: unknown},
596596
): void {
597597
// Fast path: if this event isn't one the responder system cares about and

‎packages/react-native/src/private/renderer/events/dispatchNativeEvent.js‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,23 @@ import {
3737
* enableNativeEventTargetEventDispatching is enabled.
3838
*/
3939
export default function dispatchNativeEvent(
40-
target: EventTarget,
40+
target: ?EventTarget,
4141
type: string,
4242
payload: {[string]: unknown},
4343
): void {
4444
// $FlowFixMe[incompatible-type] The global is backed by this implementation.
45-
const internalTarget = target as InternalEventTarget;
45+
const internalTarget = target as ?InternalEventTarget;
4646

4747
// Process responder events before normal event dispatch.
4848
processResponderEvent(type, internalTarget, payload);
4949

5050
try {
51+
// The target is missing when its view unmounted during the touch, so only
52+
// the responder step above applies.
53+
if (internalTarget == null) {
54+
return;
55+
}
56+
5157
// Normal EventTarget dispatch
5258
const bubbleConfig = customBubblingEventTypes[type];
5359
const directConfig = customDirectEventTypes[type];

0 commit comments

Comments
 (0)