Skip to content

Fix gesture relations after freeze - #4244

Merged
m-bert merged 1 commit into
mainfrom
@mbert/frozen-gestures
Jun 9, 2026
Merged

Fix gesture relations after freeze#4244
m-bert merged 1 commit into
mainfrom
@mbert/frozen-gestures

Conversation

@m-bert

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

Copy link
Copy Markdown
Collaborator

Description

#3763 fixed memory leak where gestures would keep stale references in their relations array. However, this approach introduced a bug where relations wouldn't be applied after freezing (like freezeOnBlur from react-native-screens). This PR addresses this issue by fixing incorrectly applied relations without re-introducing memory-leaks.

To do so, we create snapshot of relations at first render. It is later used to prepare relations during next renders.

Fixes #4238

Test plan

Tested on the following reproduction
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import { enableFreeze } from 'react-native-screens';

enableFreeze(true);

type RootStackParamList = {
  Tabs: undefined;
};

type TabParamList = {
  TabOne: undefined;
  TabTwo: undefined;
  Leak: undefined;
};

const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>();

function TabOneScreen() {
  const outerTap = Gesture.Tap().onStart(() => void console.log('outer tap'));
  const innerTap = Gesture.Tap()
    .onStart(() => void console.log('inner tap'))
    .simultaneousWithExternalGesture(outerTap);

  return (
    <View style={styles.screen1}>
      <GestureDetector gesture={outerTap}>
        <GestureDetector gesture={innerTap}>
          <View style={{ width: 100, height: 100, backgroundColor: 'red' }} />
        </GestureDetector>
      </GestureDetector>
    </View>
  );
}

// Demonstrates that when the composition's partner gesture changes across
// renders, the stable gesture's resolved relation follows the CURRENT partner
// and never accumulates the old one. `stableTap` is a single instance reused
// across renders (memoized); only the partner it is composed with changes.
function TabTwoScreen() {
  const [useB, switchPartner] = React.useReducer((value) => !value, false);

  const stableTap = React.useMemo(() => Gesture.Tap(), []);
  const partnerA = React.useMemo(
    () => Gesture.Tap().onStart(() => void console.log('▶ partner A')),
    []
  );
  const partnerB = React.useMemo(
    () => Gesture.Tap().onStart(() => void console.log('▶ partner B')),
    []
  );

  const activePartner = useB ? partnerB : partnerA;

  // Re-bind the log each render so it reports the partner that is active now.
  // We identify each stored relation by object identity (which partner gesture
  // it points at), because the GestureDetector recycles native handler slots by
  // position - so the numeric `tag` stays the same across a switch even though
  // the partner reference correctly changes from A to B. The key things to watch
  // are that `partner` follows the active one and the array length stays 1
  // (no stale accumulation).
  stableTap.onStart(() => {
    const resolved = (stableTap.config.simultaneousWith ?? []).map((ref) => ({
      partner: ref === partnerA ? 'A' : ref === partnerB ? 'B' : '?',
      tag: (ref as { handlerTag?: number }).handlerTag,
    }));
    console.log(
      `▶ stable tap | active=${useB ? 'B' : 'A'} resolved=${JSON.stringify(
        resolved
      )}`
    );
  });

  // Recreated every render because it depends on state — this is exactly the
  // case where the partner array passed into the composition changes.
  const composed = Gesture.Simultaneous(stableTap, activePartner);

  return (
    <View style={styles.screen2}>
      <GestureDetector gesture={composed}>
        <View style={styles.box}>
          <Text style={styles.boxLabel}>Tap me</Text>
          <Text style={styles.boxLabel}>partner: {useB ? 'B' : 'A'}</Text>
        </View>
      </GestureDetector>
      <Pressable onPress={switchPartner} style={styles.button}>
        <Text style={styles.buttonLabel}>Switch partner</Text>
      </Pressable>
    </View>
  );
}

// Reproduces the scenario from the #3763 memory-leak fix: a stable (memoized)
// gesture composed with a gesture that is recreated on every render and captures
// a large allocation. If relations accumulated, `stable.config.simultaneousWith`
// would grow by one on every render and pin every transient gesture (and its
// `bigMemory`) in memory forever. With the fix it stays at exactly 1, so each
// previous transient gesture becomes collectible.
function LeakScreen() {
  const [renders, rerender] = React.useReducer((value) => value + 1, 0);

  // Stable instance — survives every render. This is the gesture that used to
  // accumulate references to each render's transient partner.
  const stable = React.useMemo(() => Gesture.Pan(), []);

  // Fresh big allocation + fresh transient gesture on every render. The gesture
  // captures `bigMemory`, so anything retaining the gesture also retains it.
  const bigMemory = new Array(500_000).fill(renders);
  const transient = Gesture.Pan().onStart(() => void bigMemory.length);

  const composed = Gesture.Simultaneous(stable, transient);

  // Direct proxy for the leak: how many partner references `stable` is holding.
  // Stays 1 with the fix; grew by one per render before it.
  const relationsHeld = stable.config.simultaneousWith?.length ?? 0;
  console.log(`leak check | renders=${renders} relationsHeld=${relationsHeld}`);

  // Drives many *separate* renders (and therefore many prepares) so the count
  // is easy to watch. Plain dispatch-in-a-loop would be batched into one render.
  const stress = React.useCallback(() => {
    let count = 0;
    const id = setInterval(() => {
      rerender();
      count += 1;
      if (count >= 100) {
        clearInterval(id);
      }
    }, 16);
  }, []);

  return (
    <View style={styles.screen2}>
      <GestureDetector gesture={composed}>
        <View style={styles.box}>
          <Text style={styles.boxLabel}>renders: {renders}</Text>
          <Text style={styles.boxLabel}>relations held: {relationsHeld}</Text>
        </View>
      </GestureDetector>
      <Pressable onPress={rerender} style={styles.button}>
        <Text style={styles.buttonLabel}>Re-render once</Text>
      </Pressable>
      <Pressable onPress={stress} style={styles.button}>
        <Text style={styles.buttonLabel}>Re-render x100</Text>
      </Pressable>
    </View>
  );
}

function TabsScreen() {
  return (
    <Tab.Navigator>
      <Tab.Screen name="TabOne" component={TabOneScreen} />
      <Tab.Screen name="TabTwo" component={TabTwoScreen} />
      <Tab.Screen name="Leak" component={LeakScreen} />
    </Tab.Navigator>
  );
}

const App = () => {
  return (
    <GestureHandlerRootView style={styles.root}>
      <NavigationContainer>
        <Stack.Navigator>
          <Stack.Screen
            name="Tabs"
            component={TabsScreen}
            options={{ headerShown: false }}
          />
        </Stack.Navigator>
      </NavigationContainer>
    </GestureHandlerRootView>
  );
};

export default App;

const styles = StyleSheet.create({
  root: {
    flex: 1,
  },
  screen1: {
    flex: 1,
    backgroundColor: '#f9f',
  },
  screen2: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    gap: 12,
  },
  box: {
    width: 200,
    height: 200,
    backgroundColor: 'red',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 8,
    borderRadius: 12,
  },
  boxLabel: {
    color: 'white',
    fontWeight: '600',
  },
  button: {
    paddingVertical: 12,
    paddingHorizontal: 20,
    backgroundColor: '#333',
    borderRadius: 8,
  },
  buttonLabel: {
    color: 'white',
    fontWeight: '600',
  },
});

Copilot AI review requested due to automatic review settings June 8, 2026 14:21

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

This PR fixes a regression where gesture relations (simultaneousWith, requireToFail, blocksHandlers) could stop working after a freeze/unfreeze cycle (e.g. freezeOnBlur from react-native-screens). It does so by avoiding in-place mutation of gesture.config when extracting relations, and by introducing a per-gesture snapshot of “direct” relations so composed gestures can rebuild relations each render without accumulating stale references (preventing the memory leak addressed in #3763).

Changes:

  • Stop mutating gesture.config inside extractGestureRelations, returning extracted handler tags without overwriting the original relation refs.
  • Add relationsSnapshot to BaseGesture to preserve the “directly defined” relation refs across composition and remounts.
  • Update ComposedGesture.prepareSingleGesture to rebuild relation arrays from relationsSnapshot on each prepare, preventing accumulation across renders.

Reviewed changes

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

File Description
packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts Makes relation extraction side-effect free to preserve relation refs for later re-resolution.
packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts Rebuilds composed gesture relations from a captured snapshot to avoid stale-reference accumulation.
packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts Introduces relationsSnapshot on BaseGesture to persist direct relation refs across prepares/remounts.

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

@m-bert
m-bert marked this pull request as ready for review June 8, 2026 14:38
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.

extractGestureRelations mutates gesture config in place, causing gestures to stop responding after react-freeze unfreeze

3 participants