You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 ChoicesDesign Choice 1: How should `useComposedGesture` compute its memoization dependencies?
Options Considered:
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.
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.
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.
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.
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!
Activity
github-actions commentedon Aug 19, 2026
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?
coderabbitai commentedon Aug 19, 2026
Coding Plan
Summary
useComposedGestureinuseMemoso composition hooks return a stableComposedGesturereference. This stopsuseGestureRelationsUpdaterfrom cancelling and rescheduling itsrequestAnimationFrameon every render, which was starving the nativeconfigureRelationscall.useExclusiveGestures.simultaneousWithrelations.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:
[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.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 composedtype. The returned object also embeds merged event-handler callbacks andconfig. 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 howuseGesturealready 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
ComposedGestureobject identity stable across renders when the child gestures and composed type do not change. A stable identity stopsuseGestureRelationsUpdaterfrom cancelling and rescheduling itsrequestAnimationFrameon every render, so the nativeconfigureRelationscall is delivered reliably.Task 1: Memoize the composed gesture result
Make
useComposedGesturereturn a stable object reference when its inputs are unchanged.packages/react-native-gesture-handler/src/v3/hooks/composition/useComposedGesture.ts.ComposedGestureobject inuseMemo.typeand the spread child gestures ([type, ...gestures]), consistent with the leaf-gesture memoization pattern inuseGesture.ts.containsDuplicates) and the event-handler composition behavior unchanged.useGestureare 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.
packages/react-native-gesture-handler/src/v3/hooks/composition/useSimultaneousGestures.ts,useCompetingGestures.ts, anduseExclusiveGestures.ts.useComposedGesturedirectly and does not create a new object.useExclusiveGestures.ts, remove the redundant reassignmentcomposedGesture.type = ComposedGestureName.Exclusive;. This line mutates the memoized object after creation and is a no-op, becauseuseComposedGesturealready setstype. Mutating the memoized object after return is unwanted.🤖 Prompt for AI agents
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
simultaneousWithrelations 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.
packages/react-native-gesture-handler/src/v3/detectors/useGestureRelationsUpdater.ts.relationsidentity 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.Task 2: Preserve symmetric relations during tree traversal
Stop discarding relation data that was mirrored onto a gesture by
makeSimultaneousWithSymmetric.packages/react-native-gesture-handler/src/v3/detectors/utils.ts.traverseAndConfigureRelations, the linenode.gestureRelations = prepareRelations(node.config, node.handlerTag)replaces the wholegestureRelationsobject on every traversal. This discards tags that a sibling gesture pushed into the previous object throughmakeSimultaneousWithSymmetric.relationsByTagwithout overwriting a leaf gesture's existing mirroredsimultaneousHandlers. Merge the tree-derived tags with the gesture's own declared relations and any mirrored tags, and de-duplicate.simultaneousWithcorrectness. 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
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.
packages/react-native-gesture-handler/src/__tests__/(alongsideRelationsTraversal.test.tsx).useSimultaneousGestures(pan, tap)and re-render it without changing the child gestures.pan.handlerTagintap'ssimultaneousHandlersandtap.handlerTaginpan'ssimultaneousHandlers, reusing the existingconfigureRelationsassertions style.Task 2: Add a minimal Pan plus Tap simultaneous example
Provide a standalone screen that reproduces the ticket scenario for manual verification.
apps/common-app/src/new_api/that composes one Pan gesture and one Tap gesture withuseSimultaneousGesturesand attaches the result to a singleGestureDetector.🤖 Prompt for AI agents
Research
The v3 gesture API uses hooks. Leaf gestures come from
useGesture(viausePanGesture,useTapGesture, etc.). Composition hooks (useSimultaneousGestures,useCompetingGestures,useExclusiveGestures) wrap a shareduseComposedGestureimplementation that returns aComposedGestureobject. AGestureDetectorreceives the gesture and callsuseGestureRelationsUpdater, which runsconfigureRelations/traverseAndConfigureRelations(inv3/detectors/utils.ts) to compute per-handler relation arrays, then pushes them to native throughNativeProxy.configureRelationsinside arequestAnimationFramecallback.relationUtils.tsprovidesprepareRelationsandmakeSimultaneousWithSymmetric.The relation graph computation is correct for a plain
useSimultaneousGestures(pan, tap)case. The defect is in object identity and delivery timing:useComposedGestureis not wrapped inuseMemo. It returns a newComposedGestureobject on every render.useGestureRelationsUpdatermemoizesrelationsongestureidentity and defers the native push withrequestAnimationFrame, with a cleanup that callscancelAnimationFrame.requestAnimationFrameand reschedules it on every render. Under repeated re-renders (common while a Pan gesture updates values), the nativeconfigureRelationscall can be starved and never fire before the user touches the screen. The native Android interaction manager then never receivessimultaneousHandlers, so Pan and Tap stay mutually exclusive.🚀 Next Steps
🤖 All AI agent prompts combined
💡 Iterate on the plan with:
`@coderabbitai` <feedback>💬 Have feedback or questions? Drop into our discord!