[Android] Fix handlers cancelled while awaiting leaking in the orchestrator (#4402)
## Description
On Android, cancelling a handler while it is awaiting another one (e.g.
the single tap in `Exclusive(doubleTap, singleTap)` waiting for the
double tap to fail) leaves it in the orchestrator forever. Both cleanup
paths in `cleanupFinishedHandlers` skip handlers with `isAwaiting` set,
and the rescue loop in `onHandlerStateChange` never reaches it because
`dropGestureHandler` drops interaction relations on the JS thread before
the posted cancel runs on the UI thread, so `shouldHandlerWaitForOther`
no longer matches.
The leaked handler stays in `gestureHandlers`, which makes
`ButtonViewGroup.shouldBeginWithRecordedHandlers` return `false` on
every subsequent touch. As a result all button-based touchables
(`Pressable`, `RectButton`, `BaseButton`, `Touchables`) stop responding
app-wide until the app process is restarted. The most common trigger is
unmounting a `GestureDetector` during the wait window.
This change clears `isAwaiting` when a handler reaches `STATE_CANCELLED`
or `STATE_FAILED`, since such a handler can never be resolved by the one
it was waiting for, letting the existing cleanup collect it. `STATE_END`
stays pinned, as `makeActive` relies on it to send synthetic events.
Going through `onHandlerStateChange` also covers cancel paths that never
touch the registry, e.g. `tryActivate` cancelling an awaiting handler
via `shouldBeCancelledByFinishedHandler`.
Fixes #4401
## Test plan
<details>
<summary>Tested on the following code</summary>
```tsx
import React, { useRef, useState } from 'react';
import {
Pressable as RNPressable,
StyleSheet,
Text,
View,
} from 'react-native';
import {
GestureDetector,
Pressable,
RectButton,
useExclusiveGestures,
useTapGesture,
} from 'react-native-gesture-handler';
// Repro for https://github.com/software-mansion/react-native-gesture-handler/issues/4401
// (Android): cancelling a handler while it is awaiting (Exclusive single tap
// waiting for double tap to fail) leaves it in the orchestrator forever.
//
// Steps:
// 1. Single-tap the purple box. 120ms later (inside the double-tap window,
// while the single-tap handler is awaiting) the detector unmounts itself.
// 2. Try the probe buttons below. According to the issue, ALL RNGH-based
// touchables should now be dead app-wide until app restart.
function ExclusiveBox({ onGone }: { onGone: () => void }) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const doubleTap = useTapGesture({
runOnJS: true,
numberOfTaps: 2,
onActivate: () => console.log('[repro] double tap activated'),
});
const singleTap = useTapGesture({
runOnJS: true,
requireToFail: doubleTap,
onActivate: () => console.log('[repro] single tap activated'),
onTouchesUp: () => {
// Unmount while the single-tap handler is awaiting double-tap failure
if (timer.current == null) {
timer.current = setTimeout(() => {
console.log('[repro] unmounting detector while awaiting');
onGone();
}, 120);
}
},
});
const exclusive = useExclusiveGestures(doubleTap, singleTap);
return (
<GestureDetector gesture={exclusive}>
<View style={[styles.box, { backgroundColor: 'rebeccapurple' }]}>
<Text style={styles.boxLabel}>
SINGLE-TAP ME{'\n'}(unmounts in 120ms)
</Text>
</View>
</GestureDetector>
);
}
export default function EmptyExample() {
const [mounted, setMounted] = useState(true);
const [detectorTaps, setDetectorTaps] = useState(0);
const [pressableTaps, setPressableTaps] = useState(0);
const [rectTaps, setRectTaps] = useState(0);
const [rnTaps, setRnTaps] = useState(0);
const probeTap = useTapGesture({
runOnJS: true,
onActivate: () => {
console.log('[probe] GestureDetector tap');
setDetectorTaps((n) => n + 1);
},
});
return (
<View style={styles.container}>
{mounted ? (
<ExclusiveBox onGone={() => setMounted(false)} />
) : (
<RNPressable
style={[styles.box, { backgroundColor: 'gray' }]}
onPress={() => setMounted(true)}>
<Text style={styles.boxLabel}>DETECTOR GONE — tap to remount</Text>
</RNPressable>
)}
<GestureDetector gesture={probeTap}>
<View style={[styles.probe, { backgroundColor: 'darkorange' }]}>
<Text style={styles.boxLabel}>Probe detector: {detectorTaps}</Text>
</View>
</GestureDetector>
<Pressable
style={[styles.probe, { backgroundColor: 'seagreen' }]}
onPress={() => {
console.log('[probe] RNGH Pressable');
setPressableTaps((n) => n + 1);
}}>
<Text style={styles.boxLabel}>RNGH Pressable: {pressableTaps}</Text>
</Pressable>
<RectButton
style={[styles.probe, { backgroundColor: 'steelblue' }]}
onPress={() => {
console.log('[probe] RectButton');
setRectTaps((n) => n + 1);
}}>
<Text style={styles.boxLabel}>RectButton: {rectTaps}</Text>
</RectButton>
<RNPressable
style={[styles.probe, { backgroundColor: 'dimgray' }]}
onPress={() => {
console.log('[probe] RN core Pressable');
setRnTaps((n) => n + 1);
}}>
<Text style={styles.boxLabel}>RN core Pressable: {rnTaps}</Text>
</RNPressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 16,
padding: 24,
},
box: {
width: 260,
height: 110,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
probe: {
width: 260,
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
boxLabel: {
color: 'white',
fontWeight: 'bold',
textAlign: 'center',
},
});
```
</details> M
Michał Bert committed
ce811da53eca0f745c6002cae03ba04ba9ce8848
Parent: 9c84c6d
Committed by GitHub <noreply@github.com>
on 8/7/2026, 10:14:35 AM