Commit 6b0ca7b
authored
Don't dispatch orphaned touch events (#4341)
## Description
Follow-up to #4316, fixing the root cause of the malformed touch events
on the native side.
`GestureHandler` on Android keeps two parallel pointer structures with
different lifecycles:
- `trackedPointerIDs` — which pointers belong to the gesture. Always
maintained (`startTrackingPointer` runs unconditionally) and consulted
by `wantsEvent`.
- `trackedPointers` — per-pointer position data used to build touch
events. Only maintained while `needsPointerData` is true, i.e. while
touch callbacks are attached.
When touch callbacks are attached **mid-gesture** — e.g. a callback
attached conditionally, where the attaching re-render is triggered from
`onBegin` — `needsPointerData` flips from `false` to `true` while a
pointer is already down. That pointer's DOWN was never recorded into
`trackedPointers`, but the UP still passes `wantsEvent`, so
`dispatchTouchUpEvent()`:
1. built `allTouches` from an empty map — the `null` payload that used
to reach JS without the `allTouches` key and crash the v3 update
pipeline (fixed defensively in #4316),
2. reported a pointer in `changedTouches` that the JS side has never
seen go down,
3. decremented `trackedPointersCount` that was never incremented for
that pointer, serializing `numberOfTouches: -1` and corrupting the
counter that `moveToState` uses to decide whether to dispatch
`TOUCH_CANCEL` (with multi-touch this can suppress a legitimate cancel
event).
iOS had the same asymmetry with a different failure mode:
`RNGestureHandlerPointerTracker` guards the counter (`unregisterTouch`
returns `-1`, no underflow), but still dispatched the orphaned event
with `id: -1` in `changedTouches`. Same for moves of an unregistered
touch in `touchesMoved`.
Web is not affected: its `PointerTracker` is populated unconditionally
and only *sending* is gated on `needsPointerData`, so mid-gesture flips
always produce well-formed events there.
## Test plan
<details>
<summary>Tested on the following code:</summary>
```tsx
import React, { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
// Repro / verification screen for the orphaned-touch-event fix (follow-up to
// #4316).
//
// Purple box ("flip"): touch callbacks are attached conditionally, so
// `needsPointerData` flips false -> true mid-gesture (the onBegin re-render
// attaches onTouchesUp). Press, hold ~300ms, release:
// - without the fix: an orphaned onTouchesUp fires for a pointer that never
// reported a down (Android: numberOfTouches -1, iOS: changed touch id -1;
// on pre-#4316 builds this crashed the pan change calculator instead)
// - with the fix: the orphaned up event is skipped - counter stays 0
//
// Green box ("static"): touch callbacks attached from the start - the normal
// path. Each tap must count down+up (counter += 2) with and without the fix.
export default function OrphanedTouchRepro() {
const [flipTouches, setFlipTouches] = useState(0);
const [staticTouches, setStaticTouches] = useState(0);
const [lastOrphan, setLastOrphan] = useState('none');
const [pressed, setPressed] = useState(false);
const flipPan = usePanGesture({
onBegin: () => setPressed(true),
onFinalize: () => setPressed(false),
onUpdate: () => {},
runOnJS: true,
onTouchesUp: pressed
? (e) => {
setFlipTouches((c) => c + 1);
setLastOrphan(
`numberOfTouches: ${e.numberOfTouches}, ` +
`changed id: ${e.changedTouches[0]?.id ?? 'none'}`
);
}
: undefined,
});
const staticPan = usePanGesture({
onTouchesDown: () => setStaticTouches((c) => c + 1),
onTouchesUp: () => setStaticTouches((c) => c + 1),
onUpdate: () => {},
runOnJS: true,
});
return (
<View style={styles.container}>
<Text style={styles.counter}>
flip: {flipTouches} static: {staticTouches}
</Text>
<Text style={styles.orphanInfo}>last orphaned up: {lastOrphan}</Text>
<GestureDetector gesture={flipPan}>
<View style={[styles.box, styles.flipBox]} testID="flipBox">
<Text style={styles.boxLabel}>flip: hold ~300ms (expect 0)</Text>
</View>
</GestureDetector>
<GestureDetector gesture={staticPan}>
<View style={[styles.box, styles.staticBox]} testID="staticBox">
<Text style={styles.boxLabel}>static (expect +2 per tap)</Text>
</View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
counter: {
fontSize: 16,
marginBottom: 8,
},
orphanInfo: {
fontSize: 13,
opacity: 0.6,
marginBottom: 24,
},
box: {
width: 260,
height: 150,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginVertical: 12,
},
flipBox: {
backgroundColor: 'mediumpurple',
},
staticBox: {
backgroundColor: 'mediumseagreen',
},
boxLabel: {
color: 'white',
fontSize: 16,
},
});
```
</details>1 parent 13d0070 commit 6b0ca7b
2 files changed
Lines changed: 40 additions & 11 deletions
File tree
- packages/react-native-gesture-handler
- android/src/main/java/com/swmansion/gesturehandler/core
- apple
Lines changed: 6 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
477 | 477 | | |
478 | 478 | | |
479 | 479 | | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
480 | 486 | | |
481 | 487 | | |
482 | 488 | | |
483 | | - | |
484 | 489 | | |
485 | 490 | | |
486 | 491 | | |
| |||
Lines changed: 34 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| 119 | + | |
119 | 120 | | |
120 | 121 | | |
121 | 122 | | |
122 | 123 | | |
123 | | - | |
124 | | - | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
125 | 127 | | |
126 | 128 | | |
127 | | - | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
128 | 135 | | |
129 | 136 | | |
130 | | - | |
| 137 | + | |
131 | 138 | | |
132 | 139 | | |
133 | 140 | | |
| |||
142 | 149 | | |
143 | 150 | | |
144 | 151 | | |
| 152 | + | |
145 | 153 | | |
146 | 154 | | |
147 | 155 | | |
148 | 156 | | |
149 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
150 | 163 | | |
151 | 164 | | |
152 | | - | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
153 | 170 | | |
154 | 171 | | |
155 | 172 | | |
| |||
166 | 183 | | |
167 | 184 | | |
168 | 185 | | |
| 186 | + | |
169 | 187 | | |
170 | 188 | | |
171 | 189 | | |
172 | 190 | | |
173 | | - | |
174 | | - | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
175 | 194 | | |
176 | 195 | | |
177 | | - | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
178 | 202 | | |
179 | 203 | | |
180 | | - | |
| 204 | + | |
181 | 205 | | |
182 | 206 | | |
183 | 207 | | |
| |||
0 commit comments