SIGN IN SIGN UP

[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