Skip to content

reanimatedEventHandler is passed to the plain host detector, so a non-function ends up on a DirectEventHandler prop #4428

Closed

Description

@antFrancon

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

  1. New Architecture app with Reanimated installed.
  2. Render a gesture with no worklet callbacks inside a GestureDetector, for example useNativeGesture().
  3. 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

Activity

  1. m-bert commented on Aug 17, 2026

    @m-bert
    Collaborator

    Hi @antFrancon! Thank you for submitting this issue, and a follow-up fix ❤️

    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.

    This seems like a bigger issue, I'll investigate that!

  2. antFrancon commented on Aug 17, 2026

    @antFrancon
    ContributorAuthor

    Thanks! Dumping what we found here in case it saves you time, we spent a while on this one. Caveat first: we never got a standalone repro for it, so treat it as a lead and not a proper report.

    Here's what we saw on iOS, using a local dev warning that names both sides of the binding:

    Emitting handler 21:PanGestureHandler is attached to the detector rendering [43:NativeViewGestureHandler].
    

    21 was the pan gesture of one component instance, 43 a useNativeGesture() from a different one. So a handler bound to a detector owned by another instance, not a parent/child mixup.

    The static side is the bit we're confident about. In apple/RNGestureHandlerDetector.mm, cancelAllObservationsForOwner: only ever gets called from willMoveToWindow: when the new window is nil:

    // TODO: I'm not sure whether this is the correct place for cleanup
    // Possibly allowing recycling and doing this in prepareForRecycle would be better
    - (void)willMoveToWindow:(RNGHWindow *)newWindow
    {
      if (newWindow == nil) {

    UIKit doesn't call willMoveToWindow: if the window is already nil, so a detector unmounted from a subtree that's already off window never runs it, and hits the recycle pool with its observations still live.

    Then prepareForRecycle calls setDefaultProps, which reallocates _subscribedHandlers. That set is the only thing attachHandlers: builds handlersToDetach from, so once a view has been through a recycle there's nothing left that could cancel those observations. A surviving one can still fire attachReadyHandler:, which binds to self, and re-parent a foreign handler onto whatever that view has become.

    Android doesn't have the hole, detachAllHandlers() runs from both onDetachedFromWindow() and onDropViewInstance.

    It might be the iOS half of #3965 as well. Same <Activity> symptom, fixed by #3967, but that one only touched Android and the reporter mentioned they had no iOS device to test on.

    We patch it locally by hoisting the teardown into detachAllHandlers and calling it from prepareForRecycle too, before setDefaultProps wipes _moduleId and the handler sets it needs. It has to guard on _moduleId == -1 and a null manager instead of react_native_assert, since it can now run on a view that never got props. One gotcha there: handlerManagerForModuleId: is return _managers[moduleId]; over a static std::unordered_map, so calling it with -1 inserts a null entry.

    Can send it as a PR if that's useful, but you'll know the recycling paths better than me.

  3. m-bert commented on Aug 17, 2026

    @m-bert
    Collaborator

    Thanks for a quick response @antFrancon, I've already looked at it and seems like we have the same conclusion. I've already opened #4440. Thank you for your investigation ❤️

    Also, one note to the repro you've posted:

      const tap = useTapGesture({ onActivate: () => {} });
      const worklet = usePanGesture({
        onUpdate: () => {
          'worklet';
        },
      });

    in that case both callbacks will be treated as worklets because onActivate will be workletized automatically

  4. added a commit that references this issue on Aug 18, 2026
    91ccdd0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Platform: AndroidThis issue is specific to AndroidPlatform: iOSThis issue is specific to iOSRepro providedA reproduction with a snack or repo is provided

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions