From 331fd4f2d1818c138a8124c91bc51d33f296d187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 25 Jun 2026 10:51:12 +0200 Subject: [PATCH 1/2] Add check --- .../src/__tests__/Errors.test.tsx | 65 +++++++++++++++++++ .../src/v3/detectors/NativeDetector.tsx | 3 + .../InterceptingGestureDetector.tsx | 3 + .../VirtualDetector/VirtualDetector.tsx | 18 +++-- .../detectors/useDetectorAttachmentGuard.ts | 52 +++++++++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts diff --git a/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx b/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx index b242a8fe00..64e7863dc7 100644 --- a/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx @@ -83,6 +83,71 @@ describe('VirtualDetector', () => { }); }); +describe('Sharing a gesture across detectors', () => { + beforeEach(() => (findNodeHandle as jest.Mock).mockReturnValue(123)); + + test('throws when the same gesture is attached to two detectors', () => { + function SameGestureTwoDetectors() { + const tap = useTapGesture({}); + return ( + + + + + + + + + ); + } + + expect(() => render()).toThrow( + 'Using the same gesture instance across multiple GestureDetectors is not possible. Create a separate gesture for each detector.' + ); + }); + + test('does not throw when the same gesture is reattached to another detector', () => { + // Changing the key unmounts the previous detector and mounts a new one with + // the same gesture - the old detector must release the gesture before the + // new one claims it. + function ReattachedGesture({ detectorKey }: { detectorKey: string }) { + const tap = useTapGesture({}); + return ( + + + + + + ); + } + + const { rerender } = render(); + expect(() => rerender()).not.toThrow(); + }); + + test('throws when the same gesture is attached to two virtual detectors', () => { + function SameGestureTwoVirtualDetectors() { + const tap = useTapGesture({}); + return ( + + + + + + + + + + + ); + } + + expect(() => render()).toThrow( + 'Using the same gesture instance across multiple GestureDetectors is not possible. Create a separate gesture for each detector.' + ); + }); +}); + describe('Check if descendant of root view', () => { test('gesture detector', () => { function GestureDetectorNoRootView() { diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index df84e1d977..b992052a71 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -7,6 +7,7 @@ import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; import HostGestureDetector from './HostGestureDetector'; import { ReanimatedNativeDetector } from './ReanimatedNativeDetector'; +import { useDetectorAttachmentGuard } from './useDetectorAttachmentGuard'; import { useGestureRelationsUpdater } from './useGestureRelationsUpdater'; import { ensureNativeDetectorComponent } from './utils'; @@ -38,6 +39,8 @@ export function NativeDetector< : [gesture.handlerTag]; }, [gesture]); + useDetectorAttachmentGuard(handlerTags); + // On web, we're triggering Reanimated callbacks ourselves, based on the type. // To handle this properly, we need to provide all three callbacks, so we set // all three to the Reanimated event handler. diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx index 927f1b7663..5c2377934b 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx @@ -17,6 +17,7 @@ import type { InterceptingGestureDetectorProps } from '../common'; import { AnimatedNativeDetector, nativeDetectorStyles } from '../common'; import HostGestureDetector from '../HostGestureDetector'; import { ReanimatedNativeDetector } from '../ReanimatedNativeDetector'; +import { useDetectorAttachmentGuard } from '../useDetectorAttachmentGuard'; import { useEnsureGestureHandlerRootView } from '../useEnsureGestureHandlerRootView'; import { useGestureRelationsUpdater } from '../useGestureRelationsUpdater'; import { ensureNativeDetectorComponent } from '../utils'; @@ -233,6 +234,8 @@ export function InterceptingGestureDetector< return []; }, [gesture]); + useDetectorAttachmentGuard(handlerTags); + // On web, we're triggering Reanimated callbacks ourselves, based on the type. // To handle this properly, we need to provide all three callbacks, so we set // all three to the Reanimated event handler. diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index 499211f1c8..18087ca38e 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { findNodeHandle, Platform } from 'react-native'; import { Wrap } from '../../../handlers/gestures/GestureDetector/Wrap'; @@ -6,6 +6,7 @@ import { tagMessage } from '../../../utils'; import { isComposedGesture } from '../../hooks/utils/relationUtils'; import type { DetectorCallbacks, VirtualChild } from '../../types'; import type { VirtualDetectorProps } from '../common'; +import { useDetectorAttachmentGuard } from '../useDetectorAttachmentGuard'; import { useGestureRelationsUpdater } from '../useGestureRelationsUpdater'; import { useNativeGestureRole } from '../useNativeGestureRole'; import { @@ -57,15 +58,21 @@ export function VirtualDetector< useNativeGestureRole(viewRef, props.children); + const handlerTags = useMemo( + () => + isComposedGesture(props.gesture) + ? props.gesture.handlerTags + : [props.gesture.handlerTag], + [props.gesture] + ); + + useDetectorAttachmentGuard(handlerTags); + useEffect(() => { if (viewTag === -1) { return; } - const handlerTags = isComposedGesture(props.gesture) - ? props.gesture.handlerTags - : [props.gesture.handlerTag]; - if (props.gesture.config.dispatchesAnimatedEvents) { throw new Error( tagMessage( @@ -96,6 +103,7 @@ export function VirtualDetector< }, [ viewTag, props.gesture, + handlerTags, props.userSelect, props.touchAction, props.enableContextMenu, diff --git a/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts b/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts new file mode 100644 index 0000000000..aa15c0d72d --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react'; + +import { tagMessage } from '../../utils'; + +// Maps a gesture's handlerTag to the id of the detector that currently renders +// it. Ownership is claimed on mount and released on unmount rather than being +// tied to the gesture's whole lifetime, so reattaching the same gesture to a +// different detector (e.g. a conditionally rendered one) keeps working. +// the previous detector releases the tag on unmount before the next one claims it. +const detectorByHandlerTag = new Map(); + +let nextDetectorId = 0; + +export function useDetectorAttachmentGuard(handlerTags: number[]) { + const detectorId = useRef(-1); + + if (detectorId.current === -1) { + detectorId.current = nextDetectorId++; + } + + useEffect(() => { + if (!__DEV__) { + return; + } + + const id = detectorId.current; + + for (const tag of handlerTags) { + const owner = detectorByHandlerTag.get(tag); + + if (owner !== undefined && owner !== id) { + throw new Error( + tagMessage( + 'Using the same gesture instance across multiple GestureDetectors is not possible. Create a separate gesture for each detector.' + ) + ); + } + } + + for (const tag of handlerTags) { + detectorByHandlerTag.set(tag, id); + } + + return () => { + for (const tag of handlerTags) { + if (detectorByHandlerTag.get(tag) === id) { + detectorByHandlerTag.delete(tag); + } + } + }; + }, [handlerTags]); +} From 1fe7f87fb3b7cb5832d9ee34aa23c3837d3a0a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:11:43 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/v3/detectors/useDetectorAttachmentGuard.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts b/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts index aa15c0d72d..bd41d492d0 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts +++ b/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts @@ -2,11 +2,10 @@ import { useEffect, useRef } from 'react'; import { tagMessage } from '../../utils'; -// Maps a gesture's handlerTag to the id of the detector that currently renders -// it. Ownership is claimed on mount and released on unmount rather than being -// tied to the gesture's whole lifetime, so reattaching the same gesture to a -// different detector (e.g. a conditionally rendered one) keeps working. -// the previous detector releases the tag on unmount before the next one claims it. +// Maps a gesture's handlerTag to the id of the detector currently rendering it. +// Ownership is claimed in an effect and released in its cleanup (on unmount or +// when handlerTags change), so moving a gesture instance between detectors works +// as long as it isn't rendered by more than one detector at the same time. const detectorByHandlerTag = new Map(); let nextDetectorId = 0;