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):
- Render a
GestureDetector with Gesture.Exclusive(Gesture.Tap().numberOfTaps(2), Gesture.Tap().numberOfTaps(1)).
- Single-tap it. The single-tap handler goes into the awaiting state, waiting for the double tap to fail.
- 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).
- 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
Description
On Android, if a gesture handler is cancelled while it is
isAwaiting(i.e. it is waiting foranother handler in a
Gesture.Exclusive/requireExternalGestureToFailrelation), the orchestratornever resets or removes it. It stays in
gestureHandlersandawaitingHandlersfor the lifetime ofthe 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/BaseButtonfrom RNGH, and anyGestureDetector. Nothing recoversit: 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
GestureDetectorwhile one of its gestures isawaiting: detaching the view cancels the handler, and
RNGestureHandlerRegistry.detachHandlerInternalexplicitly does this so that the orchestrator dropsits reference (see the comment on line 135) — but for an awaiting handler that cleanup silently
never happens.
Diagnosis
Instrumenting
RNGestureHandlerRootHelper.dispatchTouchEventto dump the orchestrator's handlerlist shows the leak clearly.
Healthy tap:
After the unmount-while-awaiting:
state=3isSTATE_CANCELLED. That handler is present on every subsequent touch and is neverremoved, because both cleanup paths in
GestureHandlerOrchestratorskip awaiting handlers:isAwaitingis cleared inmakeActive, and inonHandlerStateChangewhen the orchestrator itselfkills 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.detachHandlerInternaldoes on view detach(
RNGestureHandlerRegistry.kt:134-138):So the intent documented there — cancel so the orchestrator drops the reference — does not hold for
awaiting handlers.
Note that
dropGestureHandleronly callsregistry.dropHandler(handlerTag); the orchestrator isnever told to purge the handler, so the cancel is the only cleanup mechanism.
Suggested fix
Clearing
isAwaitingwhen a handler reaches a terminal state that can never be resolved makes theexisting cleanup collect it.
STATE_ENDmust stay excluded, since the orchestrator deliberatelykeeps those pinned so
makeActivecan send their synthetic events.We have been running this as a
patch-packagepatch and it resolves the problem: after the samerepro the handler list is back to
awaiting=0with no leftover handler, and all touchables work.No regressions observed so far in normal gesture use (taps, scroll, pinch, the double/single tap
Exclusivecomposition itself).An alternative would be to have
dropHandler/detachHandlerInternalpurge the handler from theorchestrator's lists directly, which would also cover handlers stuck in other states.
Environment
react-native-tvos), New Architecture enabled, Hermes disabledSteps to reproduce
Verified path (in a real app):
GestureDetectorwithGesture.Exclusive(Gesture.Tap().numberOfTaps(2), Gesture.Tap().numberOfTaps(1)).when the activity enters Android picture-in-picture, which happens right after a tap).
Expected: the touchable responds.
Actual: no gesture ever begins again — no press feedback, no
onBegin, noonPress, 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
setTimeoutfired 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