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..bd41d492d0
--- /dev/null
+++ b/packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts
@@ -0,0 +1,51 @@
+import { useEffect, useRef } from 'react';
+
+import { tagMessage } from '../../utils';
+
+// 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;
+
+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]);
+}