Skip to content

Relations do not work #4448

Closed as duplicate
Closed as duplicate

Description

@m-bert

Caution

This issue was created to test Code Rabbit possibilities

Description

I try to compose Pan and Tap into simultaneous but they don't work

export default function App() {
	return <View />;
}

Steps to reproduce

1 2 3

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/software-mansion/react-native-gesture-handler

Gesture Handler version

3.2.1

React Native version

0.87.0

Platforms

Android

JavaScript runtime

None

Workflow

None

Architecture

None

Build type

None

Device

None

Device model

No response

Acknowledgements

Yes

Activity

  1. github-actions commented on Aug 19, 2026

    @github-actions

    Hey! 👋

    The issue doesn't seem to contain a minimal reproduction.

    Could you provide a snack or a link to a GitHub repository under your username that reproduces the problem?

  2. coderabbitai commented on Aug 19, 2026

    @coderabbitai

    Coding Plan

    Summary

    • Wrap useComposedGesture in useMemo so composition hooks return a stable ComposedGesture reference. This stops useGestureRelationsUpdater from cancelling and rescheduling its requestAnimationFrame on every render, which was starving the native configureRelations call.
    • Remove the redundant post-return mutation of the memoized object in useExclusiveGestures.
    • Harden the relation updater so a short burst of re-renders cannot drop the deferred native push, and stop the tree traversal from discarding mirrored symmetric simultaneousWith relations.
    • Add a regression test for stable identity and correct simultaneousHandlers, plus a minimal Pan plus Tap example screen for manual verification.
    Design Choices
    Design Choice 1: How should `useComposedGesture` compute its memoization dependencies?

    Options Considered:

    1. Memoize on [type, ...gestures]. Spread the child gestures directly into the dependency array. The gesture count for any given call site is fixed by the rules of hooks, so the dependency array length is stable per call site.
    2. Memoize on a derived stable key (for example, a join of handlerTags) and read gestures from a ref. This adds indirection and a manual key.

    Chosen Option: 1

    Rationale: The hook takes a rest parameter (...gestures) plus a composed type. The returned object also embeds merged event-handler callbacks and config. The dependency list must cover the child gestures and the composed type without introducing a variable-length dependency array that breaks the rules of hooks across renders. Option 1 matches how useGesture already memoizes on its own inputs, keeps dependencies explicit, and the array length is stable per call site because composition hooks obey the rules of hooks.

    💡 User Tips

    Regenerate the plan with different choices with @coderabbitai <feedback>.

    Implementation Steps

    Phase 1: Stabilize the composed gesture identity

    This phase removes the root cause. It makes the ComposedGesture object identity stable across renders when the child gestures and composed type do not change. A stable identity stops useGestureRelationsUpdater from cancelling and rescheduling its requestAnimationFrame on every render, so the native configureRelations call is delivered reliably.

    Task 1: Memoize the composed gesture result

    Make useComposedGesture return a stable object reference when its inputs are unchanged.

    • Update packages/react-native-gesture-handler/src/v3/hooks/composition/useComposedGesture.ts.
    • Wrap the construction of the returned ComposedGesture object in useMemo.
    • Set the dependency list to the composed type and the spread child gestures ([type, ...gestures]), consistent with the leaf-gesture memoization pattern in useGesture.ts.
    • Keep the duplicate-tag validation (containsDuplicates) and the event-handler composition behavior unchanged.
    • Confirm the child leaf gestures from useGesture are already memoized, so their identities are stable inputs to this memo.
    Task 2: Verify the composition hook wrappers pass through the stable object

    Ensure the three public composition hooks return the memoized object without breaking identity.

    • Review packages/react-native-gesture-handler/src/v3/hooks/composition/useSimultaneousGestures.ts, useCompetingGestures.ts, and useExclusiveGestures.ts.
    • Confirm each hook returns the object from useComposedGesture directly and does not create a new object.
    • In useExclusiveGestures.ts, remove the redundant reassignment composedGesture.type = ComposedGestureName.Exclusive;. This line mutates the memoized object after creation and is a no-op, because useComposedGesture already sets type. Mutating the memoized object after return is unwanted.
    🤖 Prompt for AI agents
    Implement Phase 1: stabilize the identity of the composed gesture object so it
    does not change on every render, which is the root cause of the relation
    delivery failure.
    
    - In
    `packages/react-native-gesture-handler/src/v3/hooks/composition/useComposedGesture.ts`,
    wrap the construction of the returned `ComposedGesture` object in `useMemo`.
    - Set the dependency array to `[type, ...gestures]`, matching the memoization
    pattern already used in `useGesture.ts`.
    - Keep the existing duplicate-tag validation (`containsDuplicates`) and the
    existing event-handler composition logic unchanged; only add the memoization
    wrapper.
    - Confirm that the child leaf gestures produced by `useGesture` are already
    memoized, so they form stable inputs to this new memo.
    - Review `useSimultaneousGestures.ts`, `useCompetingGestures.ts`, and
    `useExclusiveGestures.ts` to confirm each one returns the object from
    `useComposedGesture` directly, without wrapping it in a new object.
    - In `useExclusiveGestures.ts`, remove the line `composedGesture.type =
    ComposedGestureName.Exclusive;`. This reassignment mutates the memoized object
    after creation, is redundant because `useComposedGesture` already sets `type`,
    and must not remain.
    

    Phase 2: Make relation delivery robust to re-renders

    This phase hardens the delivery path so a short window of re-renders cannot starve the native relation push. It also prevents symmetric simultaneousWith relations from being discarded when the composed tree is traversed more than once.

    Task 1: Prevent starvation of the deferred native relation push

    Ensure the computed relations reach native even if the surrounding component re-renders repeatedly.

    • Update packages/react-native-gesture-handler/src/v3/detectors/useGestureRelationsUpdater.ts.
    • Keep the intent of deferring the push until after descendant effects run.
    • Change the effect so a re-render that produces an equivalent relations map does not cancel an already-scheduled or already-completed push. With Phase 1 in place, relations identity is stable when inputs are unchanged, so the effect runs once. As defense in depth, guard the effect so a cancelled frame does not drop the relation permanently. Prefer running the push and, on cleanup, only cancelling a frame that has not yet fired.
    • Do not add native retry logic. The JS-side identity fix is the primary correction.
    Task 2: Preserve symmetric relations during tree traversal

    Stop discarding relation data that was mirrored onto a gesture by makeSimultaneousWithSymmetric.

    • Update packages/react-native-gesture-handler/src/v3/detectors/utils.ts.
    • In traverseAndConfigureRelations, the line node.gestureRelations = prepareRelations(node.config, node.handlerTag) replaces the whole gestureRelations object on every traversal. This discards tags that a sibling gesture pushed into the previous object through makeSimultaneousWithSymmetric.
    • Change the traversal so it builds the per-tag relation arrays for relationsByTag without overwriting a leaf gesture's existing mirrored simultaneousHandlers. Merge the tree-derived tags with the gesture's own declared relations and any mirrored tags, and de-duplicate.
    • This change targets broader simultaneousWith correctness. It is included because it is part of the same "relations do not work" failure class. It does not change the plain Pan plus Tap result, which already computes correctly.
    🤖 Prompt for AI agents
    Implement Phase 2: harden the native relation delivery path against re-render
    bursts, and stop the traversal logic from discarding mirrored symmetric
    relations.
    
    - In
    `packages/react-native-gesture-handler/src/v3/detectors/useGestureRelationsUpdater.ts`,
    keep the existing intent of deferring the native push until after descendant
    effects run.
    - Change the effect logic so a re-render that produces an equivalent relations
    map does not cancel an already-scheduled or already-completed native push. With
    the Phase 1 identity fix, `relations` identity is stable across renders when
    inputs do not change, so the effect should run once in the normal case.
    - As defense in depth, guard the effect so that a cancelled
    `requestAnimationFrame` does not permanently drop the relation push. Prefer
    running the push and, on cleanup, only cancelling a frame that has not yet
    fired.
    - Do not add any native-side retry logic; the correction stays on the JS side.
    - In `packages/react-native-gesture-handler/src/v3/detectors/utils.ts`, in
    `traverseAndConfigureRelations`, stop the line `node.gestureRelations =
    prepareRelations(node.config, node.handlerTag)` from overwriting the entire
    `gestureRelations` object on each traversal, since this discards tags previously
    mirrored onto it by `makeSimultaneousWithSymmetric`.
    - Rework the traversal so the per-tag relation arrays used for `relationsByTag`
    merge the tree-derived tags with the gesture's own declared relations and any
    mirrored tags, then de-duplicate the merged result, instead of overwriting the
    leaf gesture's existing mirrored `simultaneousHandlers`.
    - Note that this traversal fix targets the broader `simultaneousWith`
    correctness issue and does not alter the already-correct result for a plain Pan
    plus Tap composition.
    

    Phase 3: Regression coverage and manual reproduction

    This phase adds automated and manual verification for the fixed behavior.

    Task 1: Add a regression test for stable composed gesture identity

    Verify that composition hooks return a stable object across renders when inputs do not change.

    • Add a test in packages/react-native-gesture-handler/src/__tests__/ (alongside RelationsTraversal.test.tsx).
    • Render a component that calls useSimultaneousGestures(pan, tap) and re-render it without changing the child gestures.
    • Assert the returned composed gesture keeps the same object reference across renders.
    • Assert the computed relations map keeps pan.handlerTag in tap's simultaneousHandlers and tap.handlerTag in pan's simultaneousHandlers, reusing the existing configureRelations assertions style.
    Task 2: Add a minimal Pan plus Tap simultaneous example

    Provide a standalone screen that reproduces the ticket scenario for manual verification.

    • Add a focused example under apps/common-app/src/new_api/ that composes one Pan gesture and one Tap gesture with useSimultaneousGestures and attaches the result to a single GestureDetector.
    • Keep the screen minimal. It must show that the Tap still recognizes while the Pan is active, which confirms simultaneous recognition on Android.
    🤖 Prompt for AI agents
    Implement Phase 3: add automated regression coverage and a manual reproduction
    example for the fix.
    
    - Add a new test file in `packages/react-native-gesture-handler/src/__tests__/`,
    placed alongside `RelationsTraversal.test.tsx`.
    - In the test, render a component that calls `useSimultaneousGestures(pan,
    tap)`, then re-render it without changing the child gestures.
    - Assert that the returned composed gesture object keeps the same object
    reference across the re-render.
    - Assert that the computed relations map keeps `pan.handlerTag` inside `tap`'s
    `simultaneousHandlers`, and `tap.handlerTag` inside `pan`'s
    `simultaneousHandlers`, following the same assertion style already used for
    `configureRelations` in the existing tests.
    - Add a focused, minimal example screen under `apps/common-app/src/new_api/`
    that composes one Pan gesture and one Tap gesture using
    `useSimultaneousGestures`, and attaches the resulting composed gesture to a
    single `GestureDetector`.
    - Keep the example screen minimal, and make sure it visibly demonstrates that
    the Tap gesture still recognizes while the Pan gesture is active, confirming
    simultaneous recognition on Android.
    
    Research

    The v3 gesture API uses hooks. Leaf gestures come from useGesture (via usePanGesture, useTapGesture, etc.). Composition hooks (useSimultaneousGestures, useCompetingGestures, useExclusiveGestures) wrap a shared useComposedGesture implementation that returns a ComposedGesture object. A GestureDetector receives the gesture and calls useGestureRelationsUpdater, which runs configureRelations/traverseAndConfigureRelations (in v3/detectors/utils.ts) to compute per-handler relation arrays, then pushes them to native through NativeProxy.configureRelations inside a requestAnimationFrame callback. relationUtils.ts provides prepareRelations and makeSimultaneousWithSymmetric.

    The relation graph computation is correct for a plain useSimultaneousGestures(pan, tap) case. The defect is in object identity and delivery timing:

    • useComposedGesture is not wrapped in useMemo. It returns a new ComposedGesture object on every render.
    • useGestureRelationsUpdater memoizes relations on gesture identity and defers the native push with requestAnimationFrame, with a cleanup that calls cancelAnimationFrame.
    • Because the composed gesture identity changes on every render, the effect cleanup cancels the pending requestAnimationFrame and reschedules it on every render. Under repeated re-renders (common while a Pan gesture updates values), the native configureRelations call can be starved and never fire before the user touches the screen. The native Android interaction manager then never receives simultaneousHandlers, so Pan and Tap stay mutually exclusive.

    🚀 Next Steps

    🤖 All AI agent prompts combined
    Task: 1
    
    Implement Phase 1: stabilize the identity of the composed gesture object so it
    does not change on every render, which is the root cause of the relation
    delivery failure.
    
    - In
    `packages/react-native-gesture-handler/src/v3/hooks/composition/useComposedGesture.ts`,
    wrap the construction of the returned `ComposedGesture` object in `useMemo`.
    - Set the dependency array to `[type, ...gestures]`, matching the memoization
    pattern already used in `useGesture.ts`.
    - Keep the existing duplicate-tag validation (`containsDuplicates`) and the
    existing event-handler composition logic unchanged; only add the memoization
    wrapper.
    - Confirm that the child leaf gestures produced by `useGesture` are already
    memoized, so they form stable inputs to this new memo.
    - Review `useSimultaneousGestures.ts`, `useCompetingGestures.ts`, and
    `useExclusiveGestures.ts` to confirm each one returns the object from
    `useComposedGesture` directly, without wrapping it in a new object.
    - In `useExclusiveGestures.ts`, remove the line `composedGesture.type =
    ComposedGestureName.Exclusive;`. This reassignment mutates the memoized object
    after creation, is redundant because `useComposedGesture` already sets `type`,
    and must not remain.
    ===============================================================================
    
    Task: 2
    
    Implement Phase 2: harden the native relation delivery path against re-render
    bursts, and stop the traversal logic from discarding mirrored symmetric
    relations.
    
    - In
    `packages/react-native-gesture-handler/src/v3/detectors/useGestureRelationsUpdater.ts`,
    keep the existing intent of deferring the native push until after descendant
    effects run.
    - Change the effect logic so a re-render that produces an equivalent relations
    map does not cancel an already-scheduled or already-completed native push. With
    the Phase 1 identity fix, `relations` identity is stable across renders when
    inputs do not change, so the effect should run once in the normal case.
    - As defense in depth, guard the effect so that a cancelled
    `requestAnimationFrame` does not permanently drop the relation push. Prefer
    running the push and, on cleanup, only cancelling a frame that has not yet
    fired.
    - Do not add any native-side retry logic; the correction stays on the JS side.
    - In `packages/react-native-gesture-handler/src/v3/detectors/utils.ts`, in
    `traverseAndConfigureRelations`, stop the line `node.gestureRelations =
    prepareRelations(node.config, node.handlerTag)` from overwriting the entire
    `gestureRelations` object on each traversal, since this discards tags previously
    mirrored onto it by `makeSimultaneousWithSymmetric`.
    - Rework the traversal so the per-tag relation arrays used for `relationsByTag`
    merge the tree-derived tags with the gesture's own declared relations and any
    mirrored tags, then de-duplicate the merged result, instead of overwriting the
    leaf gesture's existing mirrored `simultaneousHandlers`.
    - Note that this traversal fix targets the broader `simultaneousWith`
    correctness issue and does not alter the already-correct result for a plain Pan
    plus Tap composition.
    ===============================================================================
    
    Task: 3
    
    Implement Phase 3: add automated regression coverage and a manual reproduction
    example for the fix.
    
    - Add a new test file in `packages/react-native-gesture-handler/src/__tests__/`,
    placed alongside `RelationsTraversal.test.tsx`.
    - In the test, render a component that calls `useSimultaneousGestures(pan,
    tap)`, then re-render it without changing the child gestures.
    - Assert that the returned composed gesture object keeps the same object
    reference across the re-render.
    - Assert that the computed relations map keeps `pan.handlerTag` inside `tap`'s
    `simultaneousHandlers`, and `tap.handlerTag` inside `pan`'s
    `simultaneousHandlers`, following the same assertion style already used for
    `configureRelations` in the existing tests.
    - Add a focused, minimal example screen under `apps/common-app/src/new_api/`
    that composes one Pan gesture and one Tap gesture using
    `useSimultaneousGestures`, and attaches the resulting composed gesture to a
    single `GestureDetector`.
    - Keep the example screen minimal, and make sure it visibly demonstrates that
    the Tap gesture still recognizes while the Pan gesture is active, confirming
    simultaneous recognition on Android.
    
    💡 Iterate on the plan with: `@coderabbitai` <feedback>
    Example Feedback
    - `@coderabbitai` You can skip phase 3. Add a simple unit test case for phase 2.
    - `@coderabbitai` For design choice 1 go ahead with option 3 and replan.
    

    💬 Have feedback or questions? Drop into our discord!

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