Skip to content

[Android] Fix handlers cancelled while awaiting leaking in the orchestrator - #4402

Merged
m-bert merged 1 commit into
mainfrom
@mbert/fix-awaiting-handler-leak
Aug 7, 2026
Merged

[Android] Fix handlers cancelled while awaiting leaking in the orchestrator#4402
m-bert merged 1 commit into
mainfrom
@mbert/fix-awaiting-handler-leak

Conversation

@m-bert

@m-bert m-bert commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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

Tested on the following code
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',
  },
});

Copilot AI review requested due to automatic review settings August 7, 2026 09:30
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 119c9467-0e01-4223-965c-7ad5c7bfa707

📥 Commits

Reviewing files that changed from the base of the PR and between 73c51c7 and 63da7a7.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved gesture handling by properly clearing pending states when an awaiting gesture is cancelled or fails.
    • Prevents handlers from remaining stuck in an awaiting state after unsuccessful gesture recognition.

Walkthrough

The Android gesture orchestrator now clears isAwaiting when an awaiting handler reaches CANCELLED or FAILED, allowing existing finished-handler cleanup to process it.

Changes

Android awaiting handler cleanup

Layer / File(s) Summary
Clear awaiting state on terminal failure
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
Awaiting handlers clear isAwaiting before finished-state processing when they are cancelled or fail recognition.

Suggested reviewers: copilot, j-piasecki, coado

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Android cleanup bug for handlers cancelled while awaiting.
Linked Issues check ✅ Passed The change clears isAwaiting for cancelled or failed handlers, enabling existing cleanup and preserving STATE_END behavior required by issue #4401.
Out of Scope Changes check ✅ Passed The six-line change is limited to the orchestrator cleanup behavior described in issue #4401.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an Android-side leak in GestureHandlerOrchestrator where a handler that gets cancelled/failed while isAwaiting could remain recorded indefinitely, eventually preventing RNGH-based touchables/gestures from beginning until app restart (as described in #4401).

Changes:

  • Clear handler.isAwaiting when the handler transitions to STATE_CANCELLED or STATE_FAILED inside onHandlerStateChange.
  • Allow the existing cleanupFinishedHandlers() logic to reset/remove these terminal-state handlers now that they’re no longer excluded by the !handler.isAwaiting guard.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@m-bert
m-bert requested a review from j-piasecki August 7, 2026 09:44
@m-bert
m-bert merged commit ce811da into main Aug 7, 2026
5 checks passed
@m-bert
m-bert deleted the @mbert/fix-awaiting-handler-leak branch August 7, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancelled-while-awaiting handler is never cleaned up, permanently blocking all gestures (Android)

3 participants