Description
NativeDetector always passes reanimatedEventHandler to the native component, even when the gesture does not use the Reanimated detector:
// src/v3/detectors/NativeDetector.tsx
: {
onGestureHandlerReanimatedEvent:
gesture.detectorCallbacks.reanimatedEventHandler,
};
The issue is that two different conditions decide whether the handler exists and whether the Reanimated detector is used.
useGestureCallbacks builds the handler whenever disableReanimated is not set:
// src/v3/hooks/useGestureCallbacks.ts:53
let reanimatedEventHandler;
if (!config.disableReanimated) {
shouldUseReanimatedDetector also requires worklet callbacks:
// src/v3/hooks/utils/configUtils.ts:80
config.shouldUseReanimatedDetector =
!config.disableReanimated &&
Reanimated !== undefined &&
hasWorkletEventHandlers(config) &&
!config.dispatchesAnimatedEvents;
So a gesture with no worklet callbacks gets shouldUseReanimatedDetector === false and renders the plain HostGestureDetector, which forwards props as they are. But reanimatedEventHandler is still defined, so the host component receives Reanimated's { workletEventHandler } object instead of a function.
onGestureHandlerReanimatedEvent is a codegen DirectEventHandler. React checks that the listener is a function in getListener, so when something dispatches that event through one of these detectors you get:
Expected `onGestureHandlerReanimatedEvent` listener to be a function, instead got a value of `object` type.
Most of the time it stays hidden. A handler on a plain detector gets dispatchesReanimatedEvents: shouldUseReanimatedDetector && !runOnJS, which is false, so it never emits the event. It only becomes visible when a handler ends up attached to a detector that is not its own. That is how we found it, and it threw on every gesture frame.
Composed gestures are affected the same way, since useComposedGesture calls useComposedEventHandler unconditionally. That includes RNGH's own Pressable and Touchable.
We saw it on iOS, but the branch above is the shared non-web path, so Android runs the same code.
Suggested fix
Only pass the handler when the Reanimated detector is actually used:
: {
onGestureHandlerReanimatedEvent: gesture.config
.shouldUseReanimatedDetector
? gesture.detectorCallbacks.reanimatedEventHandler
: undefined,
};
The prop is part of the static view config, so undefined only means there is no JS listener. Native still emits the event.
Happy to open a PR if you want.
Steps to reproduce
- New Architecture app with Reanimated installed.
- Render a gesture with no worklet callbacks inside a
GestureDetector, for example useNativeGesture().
- Check what reaches the host component.
onGestureHandlerReanimatedEvent is an object, not a function.
The repro app shows four gestures side by side and prints, for each one, which detector NativeDetector renders and the typeof of the handler it gets. The first three are wrong, the last one is a worklet gesture as a control.
A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.
https://github.com/antFrancon/rngh-reanimated-handler-repro
Gesture Handler version
3.2.1 (also unchanged on main)
React Native version
0.86.2
Platforms
iOS, Android
JavaScript runtime
Hermes
Workflow
Using Expo Prebuild or an Expo development build
Architecture
New Architecture (Fabric)
Build type
Debug mode
Device
iOS simulator
Device model
iPhone 17 (iOS 26.5)
Acknowledgements
Yes
Description
NativeDetectoralways passesreanimatedEventHandlerto the native component, even when the gesture does not use the Reanimated detector:The issue is that two different conditions decide whether the handler exists and whether the Reanimated detector is used.
useGestureCallbacksbuilds the handler wheneverdisableReanimatedis not set:shouldUseReanimatedDetectoralso requires worklet callbacks:So a gesture with no worklet callbacks gets
shouldUseReanimatedDetector === falseand renders the plainHostGestureDetector, which forwards props as they are. ButreanimatedEventHandleris still defined, so the host component receives Reanimated's{ workletEventHandler }object instead of a function.onGestureHandlerReanimatedEventis a codegenDirectEventHandler. React checks that the listener is a function ingetListener, so when something dispatches that event through one of these detectors you get:Most of the time it stays hidden. A handler on a plain detector gets
dispatchesReanimatedEvents: shouldUseReanimatedDetector && !runOnJS, which is false, so it never emits the event. It only becomes visible when a handler ends up attached to a detector that is not its own. That is how we found it, and it threw on every gesture frame.Composed gestures are affected the same way, since
useComposedGesturecallsuseComposedEventHandlerunconditionally. That includes RNGH's ownPressableandTouchable.We saw it on iOS, but the branch above is the shared non-web path, so Android runs the same code.
Suggested fix
Only pass the handler when the Reanimated detector is actually used:
The prop is part of the static view config, so
undefinedonly means there is no JS listener. Native still emits the event.Happy to open a PR if you want.
Steps to reproduce
GestureDetector, for exampleuseNativeGesture().onGestureHandlerReanimatedEventis an object, not a function.The repro app shows four gestures side by side and prints, for each one, which detector
NativeDetectorrenders and thetypeofof the handler it gets. The first three are wrong, the last one is a worklet gesture as a control.A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.
https://github.com/antFrancon/rngh-reanimated-handler-repro
Gesture Handler version
3.2.1 (also unchanged on main)
React Native version
0.86.2
Platforms
iOS, Android
JavaScript runtime
Hermes
Workflow
Using Expo Prebuild or an Expo development build
Architecture
New Architecture (Fabric)
Build type
Debug mode
Device
iOS simulator
Device model
iPhone 17 (iOS 26.5)
Acknowledgements
Yes