Skip to content

Conversation

@j-piasecki
Copy link
Member

Description

Currently, when creating a gesture or configuring relations, flushOperations is called immediately. When rendering multiple detectors in the same batch, this adds unnecessary overhead as it can be done a single time at the end of the batch. This PR changes that.

I also noticed that createGestureHandler is scheduled on the UI anyway, so I figured there may be no point in keeping it synchronous.

Test plan

Used this stress test to measure impact:
import React, { Profiler, useEffect, useRef } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { GestureDetector, usePan } from 'react-native-gesture-handler';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';

const DATA = new Array(500).fill(null).map((_, i) => `Item ${i + 1}`);

function Item() {
  const translateX = useSharedValue(0);

  const style = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: translateX.value }],
    };
  });

  const pan = usePan({
    onUpdate: (event) => {
      'worklet';
      translateX.value += event.handlerData.changeX;
    },
  });

  return (
    <View style={{ height: 80, padding: 16, backgroundColor: 'gray' }}>
      <GestureDetector gesture={pan}>
        {/* <View collapsable={false} style={{opacity: 0.5}}> */}
        <Animated.View
          style={[
            { backgroundColor: 'red', height: '100%', aspectRatio: 1 },
            style,
          ]}
        />
        {/* </View> */}
      </GestureDetector>
    </View>
  );
}

function Benchmark() {
  return (
    <ScrollView
      style={{ flex: 1 }}
      contentContainerStyle={{ flexGrow: 1, gap: 8 }}>
      {DATA.map((_, index) => (
        <Item key={index} />
      ))}
    </ScrollView>
  );
}

const TIMES = 30;

export default function EmptyExample() {
  const times = useRef<number[]>([]).current;
  const [visible, setVisible] = React.useState(false);

  useEffect(() => {
    if (!visible && times.length < TIMES) {
      setTimeout(() => {
        setVisible(true);
      }, 24);
    }

    if (times.length === TIMES) {
      // calculate average, but remove highest and lowest
      const sortedTimes = [...times].sort((a, b) => a - b);
      sortedTimes.shift();
      sortedTimes.pop();
      const avgTime =
        sortedTimes.reduce((sum, time) => sum + time, 0) / sortedTimes.length;

      console.log(`Average render time: ${avgTime} ms`);
    }
  }, [visible]);

  return (
    <View style={styles.container}>
      {visible && (
        <Profiler
          id="v3"
          onRender={(_id, _phase, actualDuration) => {
            times.push(actualDuration);
            setTimeout(() => {
              setVisible(false);
            }, 24);
          }}>
          <Benchmark />
        </Profiler>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

I saw the average time fall from ~610ms to ~595ms.

currentGestureRef.current = { type, tag };
RNGestureHandlerModule.createGestureHandler(type, tag, {});
RNGestureHandlerModule.flushOperations();
// It's possible that this can cause errors about handler not being created when attemting to mound NativeDetector
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// It's possible that this can cause errors about handler not being created when attemting to mound NativeDetector
// It's possible that this can cause errors about handler not being created when attempting to mount NativeDetector

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, isn't it connected with the error that I've shown you offline? The one when app would sometimes crash on startup because handler has not yet been created?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't know - it cannot be the cause, since it wasn't there when you found it, but I also was able to reproduce that only by modifying RNGH source while the app was running.

Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
@j-piasecki j-piasecki merged commit 12ffcc4 into next Nov 24, 2025
8 checks passed
@j-piasecki j-piasecki deleted the @jpiasecki/schedule-flushes branch November 24, 2025 09:39
j-piasecki added a commit that referenced this pull request Nov 25, 2025
## Description

This takes things a step further than
#3830.

By wrapping every call to the native module (from V3) with
`NativeProxy`, we have a centralized place that communication with the
module always goes through. Using that, most needed operations are
batched. The exceptions are `updateGestureHandlerConfig` (see comment)
and `createGestureHandler`, which needs to run synchronously. This
should reduce the amount of unnecessary native calls.

On web, "everything is native" so no batching there. We may investigate
the potential gains in the future, but due to differences in scheduling
the same approach as on Android/iOS won't work.

I've also changed `createGestureHandler` on iOS to create the
`UIGestureRecognizer` immediately. This solved a weird "double free"
crash I've been seeing.

## Test plan

Tested on the same code as
#3830

I saw improvements from ~600ms to ~595ms
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.

4 participants