From dc3360696f377f9ea4982b97a2ca368f17d3d3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 2 Oct 2026 15:58:09 +0200 Subject: [PATCH 1/4] Link late-mounted gesture objects and skip mount reactions without external relations --- .../src/__tests__/useMountReactions.test.ts | 122 +++++++++++++++++- .../GestureDetector/attachHandlers.ts | 10 +- .../gestures/GestureDetector/index.tsx | 4 + .../gestures/GestureDetector/types.ts | 3 + .../GestureDetector/updateHandlers.ts | 4 + .../GestureDetector/useMountReactions.ts | 27 +++- .../gestures/GestureDetector/utils.ts | 34 +++++ 7 files changed, 192 insertions(+), 12 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/useMountReactions.test.ts b/packages/react-native-gesture-handler/src/__tests__/useMountReactions.test.ts index f9d561e555..fe4e03918f 100644 --- a/packages/react-native-gesture-handler/src/__tests__/useMountReactions.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/useMountReactions.test.ts @@ -1,8 +1,11 @@ import { renderHook } from '@testing-library/react-native'; import type { Platform as PlatformModule } from 'react-native'; +import type { GestureType } from '../handlers/gestures/gesture'; +import { BaseGesture } from '../handlers/gestures/gesture'; import type { AttachedGestureState } from '../handlers/gestures/GestureDetector/types'; import { useMountReactions } from '../handlers/gestures/GestureDetector/useMountReactions'; +import { hasExternalRelations } from '../handlers/gestures/GestureDetector/utils'; import { MountRegistry } from '../mountRegistry'; // The relation scan used to resolve entries through `transformIntoHandlerTags`, @@ -36,6 +39,7 @@ const stateWithRelation = ( animatedHandlers: null, shouldUseReanimated: false, isMounted: true, + hasExternalRelations: true, }) as unknown as AttachedGestureState; const mount = (handlerTag: number) => @@ -43,6 +47,13 @@ const mount = (handlerTag: number) => handlerTag, } as unknown as React.Component); +const gestureObject = (handlerTag: number, config: object = {}) => { + const gesture = Object.create(BaseGesture.prototype) as GestureType; + gesture.handlerTag = handlerTag; + gesture.config = config as GestureType['config']; + return gesture; +}; + describe('useMountReactions', () => { const relationKeys: RelationKey[] = [ 'blocksHandlers', @@ -97,14 +108,12 @@ describe('useMountReactions', () => { unmount(); }); - test('ignores entries that already carried their tag when the detector attached', () => { + test('ignores raw tags, which cannot change after the detector attached', () => { const updateDetector = jest.fn(); - // A gesture object and a raw tag are both resolved by the time the detector - // attaches, so mounting cannot change what they point at. const { unmount } = renderHook(() => useMountReactions( updateDetector, - stateWithRelation('simultaneousWith', [{ handlerTag: 42 }, 42]) + stateWithRelation('simultaneousWith', [42]) ) ); @@ -114,6 +123,77 @@ describe('useMountReactions', () => { unmount(); }); + test('updates the detector when a gesture object in a relation mounts later', () => { + const updateDetector = jest.fn(); + // The object had no tag when this detector attached; its own detector + // assigns one in `initialize` and then fires the mount event with it. + const external = gestureObject(-1); + const { unmount } = renderHook(() => + useMountReactions( + updateDetector, + stateWithRelation('simultaneousWith', [external]) + ) + ); + + external.handlerTag = 42; + MountRegistry.gestureWillMount(external); + + expect(updateDetector).toHaveBeenCalledTimes(1); + unmount(); + }); + + test('does not match a different gesture object with the same tag', () => { + const updateDetector = jest.fn(); + const { unmount } = renderHook(() => + useMountReactions( + updateDetector, + stateWithRelation('simultaneousWith', [gestureObject(42)]) + ) + ); + + MountRegistry.gestureWillMount(gestureObject(42)); + + expect(updateDetector).not.toHaveBeenCalled(); + unmount(); + }); + + test('skips the mount of its own gestures', () => { + const updateDetector = jest.fn(); + // Composition fills relations with sibling gestures, so without the guard + // every composed detector would update itself on mount. + const first = gestureObject(1); + const second = gestureObject(2); + first.config = { simultaneousWith: [second] } as GestureType['config']; + second.config = { simultaneousWith: [first] } as GestureType['config']; + const state = stateWithRelation('simultaneousWith', []); + state.attachedGestures = [first, second]; + const { unmount } = renderHook(() => + useMountReactions(updateDetector, state) + ); + + MountRegistry.gestureWillMount(first); + MountRegistry.gestureWillMount(second); + + expect(updateDetector).not.toHaveBeenCalled(); + unmount(); + }); + + test('skips the scan when the detector has no external relations', () => { + const updateDetector = jest.fn(); + const state = stateWithRelation('simultaneousWith', [ + { current: { handlerTag: 42 } }, + ]); + state.hasExternalRelations = false; + const { unmount } = renderHook(() => + useMountReactions(updateDetector, state) + ); + + mount(42); + + expect(updateDetector).not.toHaveBeenCalled(); + unmount(); + }); + test('does not update a detector that is already unmounted', () => { const updateDetector = jest.fn(); const state = stateWithRelation('simultaneousWith', [ @@ -130,3 +210,37 @@ describe('useMountReactions', () => { unmount(); }); }); + +describe('hasExternalRelations', () => { + test('is false without relations or with raw tags only', () => { + expect(hasExternalRelations([gestureObject(1)])).toBe(false); + expect( + hasExternalRelations([gestureObject(1, { requireToFail: [7] })]) + ).toBe(false); + }); + + test('is false when relations only point at sibling gestures', () => { + const first = gestureObject(1); + const second = gestureObject(2); + first.config = { simultaneousWith: [second] } as GestureType['config']; + second.config = { simultaneousWith: [first] } as GestureType['config']; + + expect(hasExternalRelations([first, second])).toBe(false); + }); + + test('is true for a ref', () => { + expect( + hasExternalRelations([ + gestureObject(1, { blocksHandlers: [{ current: null }] }), + ]) + ).toBe(true); + }); + + test('is true for a gesture object attached by another detector', () => { + expect( + hasExternalRelations([ + gestureObject(1, { simultaneousWith: [gestureObject(2)] }), + ]) + ).toBe(true); + }); +}); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts index 6d7358726b..e5541d84f7 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts @@ -16,6 +16,7 @@ import { ALLOWED_PROPS, checkGestureCallbacksForWorklets, extractGestureRelations, + hasExternalRelations, } from './utils'; interface AttachHandlersConfig { @@ -73,9 +74,16 @@ export function attachHandlers({ ); } + preparedGesture.hasExternalRelations = + hasExternalRelations(gesturesToAttach); + scheduleFlushOperations(); }); + // Set before the mount events below fire, so this detector's own mount + // listener can tell its gestures apart from external ones + preparedGesture.attachedGestures = gesturesToAttach; + for (const gesture of gesturesToAttach) { const actionType = gesture.shouldUseReanimated ? ActionType.REANIMATED_WORKLET @@ -101,8 +109,6 @@ export function attachHandlers({ MountRegistry.gestureWillMount(gesture); } - preparedGesture.attachedGestures = gesturesToAttach; - if (preparedGesture.animatedHandlers) { const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated; diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index d42b2a5f9b..21e7facdb4 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -117,6 +117,10 @@ export const GestureDetector = (props: GestureDetectorProps) => { animatedHandlers: null, shouldUseReanimated: shouldUseReanimated, isMounted: false, + // The real value is computed in the attach microtask, which runs after + // this commit's effects. Until then react to every mount, so a relation + // to a gesture mounting in the same commit is never skipped. + hasExternalRelations: true, }).current; const updateAttachedGestures = useDetectorUpdater( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts index 0ed7d805d2..4e3b6a2061 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts @@ -14,6 +14,9 @@ export interface AttachedGestureState { shouldUseReanimated: boolean; // Whether the GestureDetector is mounted isMounted: boolean; + // Whether any attached gesture has a relation to a gesture outside this + // detector (a ref or a gesture object), which may resolve on a later mount + hasExternalRelations: boolean; } export interface GestureDetectorState { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts index a26739a127..78d8473384 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -9,6 +9,7 @@ import { ALLOWED_PROPS, checkGestureCallbacksForWorklets, extractGestureRelations, + hasExternalRelations, } from './utils'; export function updateHandlers( @@ -78,6 +79,9 @@ export function updateHandlers( registerHandler(handler.handlerTag, handler, handler.config.testId); } + preparedGesture.hasExternalRelations = + hasExternalRelations(attachedGestures); + if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) { const newHandlersValue = attachedGestures .filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts index 87be2c7b40..fb1702bc59 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts @@ -1,23 +1,27 @@ import { useEffect } from 'react'; +import type { GestureMountListener } from '../../../mountRegistry'; import { MountRegistry } from '../../../mountRegistry'; import type { GestureRef } from '../gesture'; import type { AttachedGestureState } from './types'; +type MountedGesture = Parameters[0]; + function shouldUpdateDetector( relation: GestureRef[] | undefined, - gesture: { handlerTag: number } + gesture: MountedGesture ) { if (relation === undefined) { return false; } for (const entry of relation) { - // Only refs can start pointing at a different handler after the detector - // attached, because `current` is filled in when the gesture they point at - // mounts. Gestures and numeric tags already carry their tag by then, so a - // mount can never change what they resolve to and scanning them is wasted - // work on every mount of every detector. + // A gesture object gets its tag in `initialize`, when its own detector + // attaches. The mount event carries that very object, so identity is enough. + if (entry === gesture) { + return true; + } + if (entry === null || typeof entry !== 'object' || !('current' in entry)) { continue; } @@ -44,6 +48,17 @@ export function useMountReactions( return; } + // Nothing here can resolve late, skip the scan (most detectors) + if (!state.hasExternalRelations) { + return; + } + + // Own gestures are resolved by the attach microtask, and on a reattach + // they would match the sibling entries added by composition + if (state.attachedGestures.includes(gesture as never)) { + return; + } + // At this point the ref in the gesture config should be updated, so we can check if one of the gestures // set in a relation with the gesture got mounted. If so, we need to update the detector to propagate // the changes to the native side. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index 5bf1ea6f44..36aa89d066 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -64,6 +64,40 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { ); } +// Relations that may resolve to a different handler after this detector attached: +// refs (filled when the target mounts) and gesture objects attached by another +// detector (they get a tag in `initialize`). Siblings added by composition are +// attached together with the gesture, so they never resolve late. +export function hasExternalRelations(gestures: GestureType[]) { + for (const gesture of gestures) { + const relations = [ + gesture.config.blocksHandlers, + gesture.config.requireToFail, + gesture.config.simultaneousWith, + ]; + + for (const relation of relations) { + if (relation === undefined) { + continue; + } + + for (const entry of relation) { + if (typeof entry === 'number') { + continue; + } + + // Anything but our own gesture object is external: a ref, or a gesture + // attached by another detector + if (!(entry instanceof BaseGesture) || !gestures.includes(entry)) { + return true; + } + } + } + } + + return false; +} + export function extractGestureRelations(gesture: GestureType) { const requireToFail = extractValidHandlerTags(gesture.config.requireToFail); const simultaneousWith = extractValidHandlerTags( From fbd21079077fbd10958531862b021d135217fc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 2 Oct 2026 16:24:41 +0200 Subject: [PATCH 2/4] set flag to true --- .../src/handlers/gestures/GestureDetector/attachHandlers.ts | 3 +++ .../src/handlers/gestures/GestureDetector/updateHandlers.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts index e5541d84f7..7be1516eba 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts @@ -56,6 +56,9 @@ export function attachHandlers({ registerHandler(handler.handlerTag, handler, handler.config.testId); } + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because all refs should be initialized // when it's ran ghQueueMicrotask(() => { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts index 78d8473384..c731f0f81f 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -34,6 +34,9 @@ export function updateHandlers( // Store attached gestures to avoid crash when gestures changed after queueing micro task const attachedGestures = preparedGesture.attachedGestures; + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated // and handlerTags in BaseGesture references should be updated in the loop above (we need to wait // in case of external relations) From 04d81a498f478a1f2b5d3ecfec2e9650b5e0de91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 2 Oct 2026 16:44:41 +0200 Subject: [PATCH 3/4] USe newGestures --- .../src/handlers/gestures/GestureDetector/updateHandlers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts index c731f0f81f..7ded8f015a 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -82,8 +82,9 @@ export function updateHandlers( registerHandler(handler.handlerTag, handler, handler.config.testId); } - preparedGesture.hasExternalRelations = - hasExternalRelations(attachedGestures); + // Classify the new instances: their configs reference the new siblings, + // which the old attached objects would mistake for external gestures + preparedGesture.hasExternalRelations = hasExternalRelations(newGestures); if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) { const newHandlersValue = attachedGestures From 591f537a5b415ebcc333cb18c6765f405d43e125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 5 Oct 2026 10:06:16 +0200 Subject: [PATCH 4/4] Copilot Set finding --- .../src/handlers/gestures/GestureDetector/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index 36aa89d066..b704255cd7 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -69,6 +69,8 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { // detector (they get a tag in `initialize`). Siblings added by composition are // attached together with the gesture, so they never resolve late. export function hasExternalRelations(gestures: GestureType[]) { + const ownGestures = new Set(gestures); + for (const gesture of gestures) { const relations = [ gesture.config.blocksHandlers, @@ -88,7 +90,7 @@ export function hasExternalRelations(gestures: GestureType[]) { // Anything but our own gesture object is external: a ref, or a gesture // attached by another detector - if (!(entry instanceof BaseGesture) || !gestures.includes(entry)) { + if (!(entry instanceof BaseGesture) || !ownGestures.has(entry)) { return true; } }