Skip to content

Ensure no gesture is used across multiple detectors#4285

Open
m-bert wants to merge 2 commits into
mainfrom
@mbert/check-for-multiple-detectors
Open

Ensure no gesture is used across multiple detectors#4285
m-bert wants to merge 2 commits into
mainfrom
@mbert/check-for-multiple-detectors

Conversation

@m-bert

@m-bert m-bert commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Description

Currently there's no error when the same gesture is used in multiple detectors. This may results in a bug, e.g. on Android and iOS only last detector captures gestures, on web all of them. This PR adds JS side check whether instance of a gesture was passed into more than one detector.

Note

The example below crashes on web, in reattaching scenario. However, this seems to be unrelated to this PR, so I left that for a follow-up.

Test plan

Tested on the following code:
import { useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { GestureDetector, useTapGesture } from 'react-native-gesture-handler';

import type { FeedbackHandle } from '../../../common';
import { COLORS, commonStyles, Feedback } from '../../../common';

type Mode = 'separate' | 'reattach' | 'shared';

// Demonstrates the guard that forbids attaching a single gesture instance to
// more than one GestureDetector at a time.
//
// - "Separate gestures (OK)"     -> each detector gets its own gesture.
// - "Reattach mode (OK)"         -> tapping the ⭐ box moves the SAME gesture to
//                                   the other detector. The detector losing it
//                                   releases it before the other claims it, so
//                                   it never throws. The ⭐ badge and its tap
//                                   counter travel with the instance, so you can
//                                   see it is literally the same gesture moving.
// - "Share one gesture (throws)" -> the SAME gesture is rendered in BOTH
//                                   detectors at once. This is the misuse we
//                                   catch - it throws on the second detector.
export default function SharedGestureExample() {
  const [mode, setMode] = useState<Mode>('separate');
  // In reattach mode, which detector currently holds the shared gesture.
  const [sharedOnTop, setSharedOnTop] = useState(true);
  // Tap count of the shared gesture - kept in a ref so incrementing it does not
  // trigger a second state update (the setSharedOnTop re-render reads it fresh).
  const sharedTapsRef = useRef(0);
  const feedbackRef = useRef<FeedbackHandle>(null);

  const sharedGesture = useTapGesture({
    onActivate: () => {
      sharedTapsRef.current += 1;
      // Tapping the box that holds the shared gesture moves (reattaches) it to
      // the other detector - exactly one detector ever holds it, so no throw.
      setSharedOnTop((prev) => !prev);
      feedbackRef.current?.showMessage('⭐ shared gesture tapped → moving');
    },
    runOnJS: true,
  });

  const topOwnGesture = useTapGesture({
    onActivate: () => feedbackRef.current?.showMessage('top gesture tapped'),
    runOnJS: true,
  });

  const bottomOwnGesture = useTapGesture({
    onActivate: () => feedbackRef.current?.showMessage('bottom gesture tapped'),
    runOnJS: true,
  });

  // Single placeholder used by whichever box does NOT currently hold the shared
  // gesture in reattach mode (mirrors the existing `reattaching` example).
  const placeholderGesture = useTapGesture({
    onActivate: () => feedbackRef.current?.showMessage('inert box tapped'),
    runOnJS: true,
  });

  // Decide which gesture each detector receives, and whether it's the shared one.
  let topGesture = topOwnGesture;
  let bottomGesture = bottomOwnGesture;
  let topHasShared = false;
  let bottomHasShared = false;

  if (mode === 'shared') {
    // Same instance in both detectors at the same time -> throws.
    topGesture = sharedGesture;
    bottomGesture = sharedGesture;
    topHasShared = true;
    bottomHasShared = true;
  } else if (mode === 'reattach') {
    // Exactly one detector holds the shared gesture at any moment -> allowed.
    topGesture = sharedOnTop ? sharedGesture : placeholderGesture;
    bottomGesture = sharedOnTop ? placeholderGesture : sharedGesture;
    topHasShared = sharedOnTop;
    bottomHasShared = !sharedOnTop;
  }

  return (
    <View style={commonStyles.centerView}>
      <View style={styles.controls}>
        <Button
          label="Separate gestures (OK)"
          onPress={() => setMode('separate')}
        />
        <Button
          label="Reattach mode (OK)"
          onPress={() => {
            setMode('reattach');
            setSharedOnTop(true);
          }}
        />
        <Button
          label="Share one gesture (throws)"
          danger
          onPress={() => setMode('shared')}
        />
      </View>

      <Text style={styles.status}>
        mode: {mode}
        {mode === 'reattach'
          ? ` · tap the ⭐ box to move the shared gesture`
          : ''}
      </Text>

      <GestureDetector gesture={topGesture}>
        <Box
          title="Top"
          color={COLORS.NAVY}
          hasShared={topHasShared}
          sharedTaps={sharedTapsRef.current}
        />
      </GestureDetector>

      <GestureDetector gesture={bottomGesture}>
        <Box
          title="Bottom"
          color={COLORS.GREEN}
          hasShared={bottomHasShared}
          sharedTaps={sharedTapsRef.current}
        />
      </GestureDetector>

      <Feedback ref={feedbackRef} />
    </View>
  );
}

function Box({
  title,
  color,
  hasShared,
  sharedTaps,
}: {
  title: string;
  color: string;
  hasShared: boolean;
  sharedTaps: number;
}) {
  return (
    <View
      style={[
        commonStyles.box,
        { backgroundColor: color },
        hasShared && styles.boxWithShared,
      ]}>
      <Text style={styles.boxText}>{title}</Text>
      {hasShared ? (
        <Text style={styles.badge}>
          ⭐ shared{'\n'}
          {sharedTaps} taps
        </Text>
      ) : (
        <Text style={styles.boxSubText}>own gesture</Text>
      )}
    </View>
  );
}

function Button({
  label,
  onPress,
  danger,
}: {
  label: string;
  onPress: () => void;
  danger?: boolean;
}) {
  return (
    <Pressable
      onPress={onPress}
      style={[styles.button, danger && styles.buttonDanger]}>
      <Text style={styles.buttonText}>{label}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  controls: {
    gap: 8,
    marginBottom: 24,
    width: '100%',
    paddingHorizontal: 16,
  },
  button: {
    backgroundColor: COLORS.NAVY,
    paddingVertical: 12,
    borderRadius: 10,
    alignItems: 'center',
  },
  buttonDanger: {
    backgroundColor: COLORS.RED,
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
  status: {
    marginBottom: 16,
    color: COLORS.NAVY,
    fontWeight: '600',
  },
  boxWithShared: {
    borderWidth: 4,
    borderColor: COLORS.KINDA_YELLOW,
  },
  boxText: {
    color: 'white',
    fontWeight: '700',
    fontSize: 18,
  },
  boxSubText: {
    color: 'white',
    opacity: 0.8,
    marginTop: 4,
  },
  badge: {
    color: COLORS.KINDA_YELLOW,
    fontWeight: '700',
    textAlign: 'center',
    marginTop: 4,
  },
});

Copilot AI review requested due to automatic review settings June 25, 2026 09:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dev-time JavaScript guard to prevent attaching the same v3 gesture instance to more than one detector simultaneously, addressing cross-platform inconsistencies (native last-detector-wins vs web multi-detector behavior).

Changes:

  • Introduces useDetectorAttachmentGuard that tracks handlerTag ownership per mounted detector and throws in __DEV__ on conflicts.
  • Wires the guard into v3 detectors (NativeDetector, VirtualDetector, InterceptingGestureDetector) using computed handlerTags.
  • Adds Jest coverage for the “shared gesture across detectors” error case (plus a reattach scenario).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx Computes handlerTags via useMemo and invokes the new attachment guard.
packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx Invokes the new attachment guard for intercepting detector handlerTags.
packages/react-native-gesture-handler/src/v3/detectors/useDetectorAttachmentGuard.ts New hook implementing the dev-only “single detector owns a handlerTag” invariant.
packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx Invokes the new attachment guard for native detector handlerTags.
packages/react-native-gesture-handler/src/tests/Errors.test.tsx Adds tests validating throws on shared gestures and a reattachment path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 36 to +43
const handlerTags = useMemo(() => {
return isComposedGesture(gesture)
? gesture.handlerTags
: [gesture.handlerTag];
}, [gesture]);

useDetectorAttachmentGuard(handlerTags);

Comment on lines +109 to +126
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 (
<GestureHandlerRootView>
<GestureDetector key={detectorKey} gesture={tap}>
<View />
</GestureDetector>
</GestureHandlerRootView>
);
}

const { rerender } = render(<ReattachedGesture detectorKey="a" />);
expect(() => rerender(<ReattachedGesture detectorKey="b" />)).not.toThrow();
});
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants