Skip to content

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

Description

@zsoltbokor

Description

On Android, if a gesture handler is cancelled while it is isAwaiting (i.e. it is waiting for
another handler in a Gesture.Exclusive/requireExternalGestureToFail relation), the orchestrator
never resets or removes it. It stays in gestureHandlers and awaitingHandlers for the lifetime of
the process, and every gesture that would have to wait for it never activates.

The practical effect is that all gesture-handler based touchables stop responding app-wide —
TouchableOpacity/RectButton/BaseButton from RNGH, and any GestureDetector. Nothing recovers
it: unmounting and remounting the screens does not help, only restarting the app does. There is no
warning or error logged.

The most common way to hit this is unmounting a GestureDetector while one of its gestures is
awaiting: detaching the view cancels the handler, and
RNGestureHandlerRegistry.detachHandlerInternal explicitly does this so that the orchestrator drops
its reference (see the comment on line 135) — but for an awaiting handler that cleanup silently
never happens.

Diagnosis

Instrumenting RNGestureHandlerRootHelper.dispatchTouchEvent to dump the orchestrator's handler
list shows the leak clearly.

Healthy tap:

handlers=2[NativeViewGestureHandler#13(state=2,active=false,awaiting=false,view=3942)
           RootViewGestureHandler#-8(state=2,active=false,awaiting=false,view=8)]
after UP: handlers=0[]

After the unmount-while-awaiting:

handlers=2[TapGestureHandler#24(state=3,active=false,awaiting=true,view=4182)
           RootViewGestureHandler#-8(state=2,active=false,awaiting=false,view=8)]  awaiting=1

state=3 is STATE_CANCELLED. That handler is present on every subsequent touch and is never
removed, because both cleanup paths in GestureHandlerOrchestrator skip awaiting handlers:

// GestureHandlerOrchestrator.kt:103
if (isFinished(handler.state) && !handler.isAwaiting) { handler.reset() … }

// GestureHandlerOrchestrator.kt:113
gestureHandlers.removeAll { isFinished(it.state) && !it.isAwaiting }

isAwaiting is cleared in makeActive, and in onHandlerStateChange when the orchestrator itself
kills an awaiting handler because the awaited one ended (GestureHandlerOrchestrator.kt:189).
It is not cleared when the handler is cancelled directly — which is exactly what
RNGestureHandlerRegistry.detachHandlerInternal does on view detach
(RNGestureHandlerRegistry.kt:134-138):

if (handler.view != null) {
  // Handler is in "prepared" state which means it is registered in the orchestrator and can
  // receive touch events. This means that before we remove it from the registry we need to
  // "cancel" it so that orchestrator does no longer keep a reference to it.
  UiThreadUtil.runOnUiThread { handler.cancel() }
}

So the intent documented there — cancel so the orchestrator drops the reference — does not hold for
awaiting handlers.

Note that dropGestureHandler only calls registry.dropHandler(handlerTag); the orchestrator is
never told to purge the handler, so the cancel is the only cleanup mechanism.

Suggested fix

Clearing isAwaiting when a handler reaches a terminal state that can never be resolved makes the
existing cleanup collect it. STATE_END must stay excluded, since the orchestrator deliberately
keeps those pinned so makeActive can send their synthetic events.

// GestureHandlerOrchestrator.onHandlerStateChange, right after `handlingChangeSemaphore += 1`
if (handler.isAwaiting &&
  (newState == GestureHandler.STATE_CANCELLED || newState == GestureHandler.STATE_FAILED)
) {
  handler.isAwaiting = false
}

We have been running this as a patch-package patch and it resolves the problem: after the same
repro the handler list is back to awaiting=0 with no leftover handler, and all touchables work.
No regressions observed so far in normal gesture use (taps, scroll, pinch, the double/single tap
Exclusive composition itself).

An alternative would be to have dropHandler/detachHandlerInternal purge the handler from the
orchestrator's lists directly, which would also cover handlers stuck in other states.

Environment

  • react-native-gesture-handler: 3.1.0 (also reproduces on 3.0.2)
  • React Native: 0.86.0-1 (react-native-tvos), New Architecture enabled, Hermes disabled
  • Platform: Android only (not tested on iOS)
  • Device: OnePlus CPH2653, Android 16 (SDK 36)
  • Build type: debug

Steps to reproduce

Verified path (in a real app):

  1. Render a GestureDetector with Gesture.Exclusive(Gesture.Tap().numberOfTaps(2), Gesture.Tap().numberOfTaps(1)).
  2. Single-tap it. The single-tap handler goes into the awaiting state, waiting for the double tap to fail.
  3. Unmount the detector while that wait is still pending (in our app the component tree is unmounted
    when the activity enters Android picture-in-picture, which happens right after a tap).
  4. Return to the app and tap any RNGH touchable anywhere in the app.

Expected: the touchable responds.
Actual: no gesture ever begins again — no press feedback, no onBegin, no onPress, on any screen,
until the app process is restarted.

I have not built a standalone Snack, but the condition is not PiP specific — any unmount of a
detector while a handler of it is awaiting should reproduce it (e.g. unmounting from a setTimeout
fired within the double-tap window, or navigating away in that window).

A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.

Gesture Handler version

3.1.0

React Native version

0.86.0.-1

Platforms

Android

JavaScript runtime

JSC

Workflow

React Native (without Expo)

Architecture

New Architecture (Fabric)

Build type

Debug mode

Device

Real device

Device model

OnePlus CPH2653, Android 16 (SDK 36)

Acknowledgements

Yes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions