Where:packages/react-native-renderer/src/legacy-events/ResponderEventPlugin.js, trackedTouchCount += 1 on any startish event and -= 1 on any end or cancel event (lines 705 to 708 on main today). The same two lines are in v0.14.0 from 2015 and every version since, and the file has never consulted changedTouches: it assumes one touch per event. That assumption held under the legacy renderer, whose receiveTouches dispatches one JS event per changed touch. It no longer holds under Fabric: RCTSurfaceTouchHandler._dispatchActiveTouches dispatches one event per target view with every changed touch inside, so a coalesced lift or cancel of two touches on one view reaches this plugin as a single event. In that sense it is a New Architecture regression in the dispatch contract rather than in this file, but this file is where the count lives.
What happens: on iOS a multi-touch cancel arrives as one topTouchCancel whose changedTouches holds every cancelled touch. Two fingers that landed in separate events add 2, the single cancel subtracts 1, and the counter stays at 1 for the rest of the session. Nothing else moves it: single taps net zero, and the only warning fires on negative drift. touchHistory.numberActiveTouches stays correct, because it is re-synced from touches.length on every event, so the two disagree.
Why it matters: with the counter above zero the plugin runs a walk on every . React Native's claims that walk unconditionally, so after one such gesture every keystroke hands the JS responder to the focused input with no finger down. The next touch anywhere outside that input starts its walk at the lowest common ancestor, never reaches the pressed element, and is dropped. The touch's own end releases the responder, so the following touch works. To a user: the first tap after typing does nothing, the second works.
selectionChangeShouldSetResponder
topSelectionChange
TextInput
Reproduction (iOS, Fabric, RN 0.85.3), no gesture library needed: a plain View large enough for two fingers, a TextInput elsewhere on the screen, and a Pressable outside the input. Put two fingers down on the View one after the other (two start events, same target), lift them together (one end event carrying both), focus the input, type one character, tap the Pressable once. The tap is dropped. Lift the fingers one at a time instead and it is not. A react-native-gesture-handler pinch reproduces it the same way, because activation cancels all touches in one event.
Evidence: a shadow of the counter kept at the app root with the same rule, logged per event, run on a plain View with no gesture handler: A_START n=1, A_START n=2 twelve milliseconds later on the same target, then a single A_END with changedTouches.length 2, and the counter left at 1. On the app's canvas: Two STARTs on the canvas, then one CANCEL with zero remaining touches, counter 1, 2, 1. A second pinch, counter 2. Then at the keystroke a selectionChange walk with numberActiveTouches 0 and the counter at 2, the input granted the responder, and the next tap dropped.
A second way it drifts: one native touch event is dispatched as one JS event per target view, and each of those events carries the full changedTouches list. Two fingers landing together on two different views give two start events, so any rule that sums changedTouches per event counts each finger twice; lift them one at a time and the counter is left at 2. Observed on a plain View with no gesture handler.
Fix: after every touch event set the counter to nativeEvent.touches.length, the number of active touches, which is what touchHistory.numberActiveTouches already does. That is right under both cases above and needs no clamping. Verified in an app patch across the prod, dev and profiling bundles: the counter reads the active-touch count throughout, returns to zero after the gesture, and the selection-change walk no longer runs at keystrokes.
Proposed change (against main, packages/react-native-renderer/src/legacy-events/ResponderEventPlugin.js, around line 704). Set the counter to the number of active touches after every touch event, which is what touchHistory.numberActiveTouches already does, instead of adding and subtracting one per event:
) {
if (isStartish(topLevelType)) {
- trackedTouchCount += 1;
+ // One native touch event can carry several touches, and is dispatched
+ // once per target view with the full changedTouches list, so neither
+ // "one per event" nor "changedTouches.length per event" is right.
+ // nativeEvent.touches is the set of active touches after this event.
+ trackedTouchCount = nativeEvent.touches
+ ? nativeEvent.touches.length
+ : trackedTouchCount + 1;
} else if (isEndish(topLevelType)) {
if (trackedTouchCount >= 0) {
- trackedTouchCount -= 1;
+ trackedTouchCount = nativeEvent.touches
+ ? nativeEvent.touches.length
+ : Math.max(0, trackedTouchCount - 1);
} else {
The fallback keeps the old behaviour for any event that carries no touches array. We run this in production through patch-package against the compiled ReactFabric-*.js bundles of React Native 0.85.3.