Skip to content

Conversation

@j-piasecki
Copy link
Member

@j-piasecki j-piasecki commented Nov 12, 2025

Description

The communication logic between VirtualDetector/InterceptingGestureDetector was complex, since it was passing callbacks through ref and everything else through state via context.

Since we're relying on Reanimated's useEvent and useComposedEventHandler, we need to trigger a rerender anyway to update the handler. This means there's no point in trying to optimize passing callbacks via refs, as we need to end with a render, and synchronizing renders and state updates requires additional logic.

This PR simplifies the communication layer to pass everything through the state, which should greatly simplify logic.

It also:

  • changes behavior of shouldUseReanimated, dispatchesAnimatedEvents flags - now it checks every virtual gesture, where previously the last registered one was the deciding factor
  • explicitly disables auto-memoization for VirtualDetector so that children changes can be detected - this should be changed to use MutationObserver once it's rolled out in RN
  • adds manual memoization to InterceptingGestureDetector

Test plan

Tested on the following snippet:

import * as React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
  GestureDetector,
  InterceptingGestureDetector,
  useTap,
} from 'react-native-gesture-handler';

import { COLORS } from './colors';

function TextWithTap() {
  const tap = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on text in its own component!');
    },
  });

  return (
    <GestureDetector gesture={tap}>
      <Text style={{ fontSize: 24, color: COLORS.KINDA_GREEN }}>
        This text is rendered in a separate component.
      </Text>
    </GestureDetector>
  );
}

function NativeDetectorExample() {
  const [entireVisible, setEntireVisible] = React.useState(true);
  const [firstVisible, setFirstVisible] = React.useState(true);
  const [secondVisible, setSecondVisible] = React.useState(true);
  const [thirdVisible, setThirdVisible] = React.useState(true);
  const [secondKey, setSecondKey] = React.useState(0);
  const [firstHasCallback, setFirstHasCallback] = React.useState(true);

  const tapAll = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on text!');
    },
  });

  const tapFirstPart = useTap({
    onStart: firstHasCallback
      ? () => {
          'worklet';
          console.log('Tapped on first part!');
        }
      : () => {
          'worklet';
          console.log('First part tapped, but no callback set.');
        },
  });

  const tapSecondPart = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on second part!');
    },
  });

  return (
    <View style={styles.subcontainer}>
      <Button
        title={(firstVisible ? 'Hide' : 'Show') + ' entire text'}
        onPress={() => setEntireVisible((v) => !v)}
      />
      <Button
        title={(firstVisible ? 'Hide' : 'Show') + ' first part'}
        onPress={() => setFirstVisible((v) => !v)}
      />
      <Button
        title={(secondVisible ? 'Hide' : 'Show') + ' second part'}
        onPress={() => setSecondVisible((v) => !v)}
      />
      <Button
        title={(thirdVisible ? 'Hide' : 'Show') + ' third part'}
        onPress={() => setThirdVisible((v) => !v)}
      />

      <Button
        title="Re-mount second part"
        onPress={() => setSecondKey((k) => k + 1)}
      />
      <Button
        title={
          (firstHasCallback ? 'Disable' : 'Enable') + ' callback on first text'
        }
        onPress={() => setFirstHasCallback((v) => !v)}
      />

      {entireVisible && (
        <InterceptingGestureDetector gesture={tapAll}>
          <Text style={{ fontSize: 18, textAlign: 'center' }}>
            Some text example running with RNGH
            {firstVisible && (
              <GestureDetector gesture={tapFirstPart}>
                <Text style={{ fontSize: 24, color: COLORS.NAVY }}>
                  {' '}
                  try tapping on this part
                </Text>
              </GestureDetector>
            )}
            {secondVisible && (
              <GestureDetector gesture={tapSecondPart}>
                <Text
                  key={secondKey}
                  style={{ fontSize: 28, color: COLORS.KINDA_BLUE }}>
                  {' '}
                  or on this part
                </Text>
              </GestureDetector>
            )}
            {thirdVisible && (
              <>
                {' '}
                <TextWithTap />
              </>
            )}{' '}
            this part is not special :(
          </Text>
        </InterceptingGestureDetector>
      )}
    </View>
  );
}

export default function NativeTextExample() {
  return (
    <View style={styles.container}>
      <NativeDetectorExample />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  subcontainer: {
    flex: 1,
    gap: 8,
    alignItems: 'center',
    justifyContent: 'center',
  },
  header: {
    fontSize: 18,
    textAlign: 'center',
    paddingHorizontal: 24,
  },
});

@j-piasecki j-piasecki force-pushed the @jpiasecki/memoize-event-handlers branch from 797299c to 86423e1 Compare November 13, 2025 09:40
@j-piasecki j-piasecki force-pushed the @jpiasecki/optimize-virtual-detector branch from 297e0cd to 19ea1a9 Compare November 13, 2025 10:20
@j-piasecki j-piasecki marked this pull request as ready for review November 13, 2025 11:59
j-piasecki added a commit that referenced this pull request Nov 13, 2025
…#3814)

## Description

Adds an early return inside `removeContextMenuListeners` so that it
doesn't throw when `detach` and `dropHandler` are called consecutively.
This is possible in v3 api, where the gesture lifecycle isn't tied to
the detector component.

## Test plan

Found when working on
#3813,
not sure how to reproduce it outside of it, but to reproduce it there
use this:
```
import * as React from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
  GestureDetector,
  InterceptingGestureDetector,
  useTap,
} from 'react-native-gesture-handler';

export const COLORS = {
  NAVY: '#001A72',
  KINDA_RED: '#FFB2AD',
  YELLOW: '#FFF096',
  KINDA_GREEN: '#C4E7DB',
  KINDA_BLUE: '#A0D5EF',
};

function TextWithTap() {
  const tap = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on text in its own component!');
    },
  });

  return (
    <GestureDetector gesture={tap}>
      <Text style={{ fontSize: 24, color: COLORS.KINDA_GREEN }}>
        This text is rendered in a separate component.
      </Text>
    </GestureDetector>
  );
}

function NativeDetectorExample() {
  const [entireVisible, setEntireVisible] = React.useState(true);
  const [firstVisible, setFirstVisible] = React.useState(true);
  const [secondVisible, setSecondVisible] = React.useState(true);
  const [thirdVisible, setThirdVisible] = React.useState(true);
  const [secondKey, setSecondKey] = React.useState(0);
  const [firstHasCallback, setFirstHasCallback] = React.useState(true);

  const tapAll = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on text!');
    },
  });

  const tapFirstPart = useTap({
    onStart: firstHasCallback ? () => {
      'worklet';
      console.log('Tapped on first part!');
    }: () => {
      'worklet';
      console.log('First part tapped, but no callback set.');
    },
  });

  const tapSecondPart = useTap({
    onStart: () => {
      'worklet';
      console.log('Tapped on second part!');
    },
  });

  return (
    <View style={styles.subcontainer}>
      <Button title={(firstVisible ? 'Hide' : 'Show') + ' entire text'} onPress={() => setEntireVisible(v => !v)} />
      <Button title={(firstVisible ? 'Hide' : 'Show') + ' first part'} onPress={() => setFirstVisible(v => !v)} />
      <Button title={(secondVisible ? 'Hide' : 'Show') + ' second part'} onPress={() => setSecondVisible(v => !v)} />
      <Button title={(thirdVisible ? 'Hide' : 'Show') + ' third part'} onPress={() => setThirdVisible(v => !v)} />

      <Button title="Re-mount second part" onPress={() => setSecondKey(k => k + 1)} />
      <Button title={(firstHasCallback ? 'Disable' : 'Enable') + ' callback on first text'} onPress={() => setFirstHasCallback(v => !v)} />

      {entireVisible && <InterceptingGestureDetector gesture={tapAll}>
        <Text style={{ fontSize: 18, textAlign: 'center' }}>
          Some text example running with RNGH
          {firstVisible && <GestureDetector gesture={tapFirstPart}>
            <Text style={{ fontSize: 24, color: COLORS.NAVY }}>
              {' '}
              try tapping on this part
            </Text>
          </GestureDetector>}
          {secondVisible && <GestureDetector gesture={tapSecondPart}>
            <Text key={secondKey} style={{ fontSize: 28, color: COLORS.KINDA_BLUE }}>
              {' '}
              or on this part
            </Text>
          </GestureDetector>}
          {thirdVisible && <>
            {' '}
            <TextWithTap />
          </>}
          {' '}
          this part is not special :(
        </Text>
      </InterceptingGestureDetector>}
    </View>
  );
}

export default function NativeTextExample() {
  return (
    <View style={styles.container}>
      <NativeDetectorExample />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  subcontainer: {
    flex: 1,
    gap: 8,
    alignItems: 'center',
    justifyContent: 'center',
  },
  header: {
    fontSize: 18,
    textAlign: 'center',
    paddingHorizontal: 24,
  },
});
```

and press `Hide entire text`
Base automatically changed from @jpiasecki/memoize-event-handlers to next November 14, 2025 08:35
Copy link
Contributor

@m-bert m-bert left a comment

Choose a reason for hiding this comment

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

LGTM, though I'll leave approval for @akwasniewski as he is the author of VirtualDetector 😅

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.

3 participants