Skip to content

Commit 6ba9d63

Browse files
authored
Fix gesture relations after freeze (#4244)
## 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 <details> <summary>Tested on the following reproduction</summary> ```tsx 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', }, }); ``` </details>
1 parent 6ce4668 commit 6ba9d63

3 files changed

Lines changed: 32 additions & 12 deletions

File tree

packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,16 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) {
6565
}
6666

6767
export function extractGestureRelations(gesture: GestureType) {
68-
gesture.config.requireToFail = extractValidHandlerTags(
69-
gesture.config.requireToFail
70-
);
71-
gesture.config.simultaneousWith = extractValidHandlerTags(
68+
const requireToFail = extractValidHandlerTags(gesture.config.requireToFail);
69+
const simultaneousWith = extractValidHandlerTags(
7270
gesture.config.simultaneousWith
7371
);
74-
gesture.config.blocksHandlers = extractValidHandlerTags(
75-
gesture.config.blocksHandlers
76-
);
72+
const blocksHandlers = extractValidHandlerTags(gesture.config.blocksHandlers);
7773

7874
return {
79-
waitFor: gesture.config.requireToFail,
80-
simultaneousHandlers: gesture.config.simultaneousWith,
81-
blocksHandlers: gesture.config.blocksHandlers,
75+
waitFor: requireToFail,
76+
simultaneousHandlers: simultaneousWith,
77+
blocksHandlers: blocksHandlers,
8278
};
8379
}
8480

packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ export abstract class BaseGesture<
137137
public handlerTag = -1;
138138
public handlerName = '';
139139
public config: BaseGestureConfig = {};
140+
// Snapshot of the relations defined directly on this gesture (e.g. via
141+
// `simultaneousWithExternalGesture`), captured before any composition extends
142+
// them. Composition rebuilds the relation config from this snapshot on every
143+
// `prepare`, so repeated renders don't accumulate references to gestures from
144+
// previous renders (memory leak, see #3763), while keeping the original
145+
// references so relations stay re-resolvable after a remount, such as a
146+
// `react-freeze` unfreeze (see #4238).
147+
public relationsSnapshot?: {
148+
simultaneousWith: GestureRef[] | undefined;
149+
requireToFail: GestureRef[] | undefined;
150+
};
140151
public handlers: HandlerCallbacks<EventPayloadT> = {
141152
gestureId: -1,
142153
handlerTag: -1,

packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,29 @@ export class ComposedGesture extends Gesture {
3131
requireGesturesToFail: GestureType[]
3232
) {
3333
if (gesture instanceof BaseGesture) {
34+
// Capture the relations defined directly on the gesture before composition
35+
// extends them, then always rebuild from that snapshot. Otherwise, when the
36+
// gesture is stable (e.g. wrapped in `useMemo`) but the composition is
37+
// recreated on every render, the relations would keep accumulating
38+
// references to gestures from previous renders, leaking memory (see #3763).
39+
// We keep the original references (instead of collapsing them to handler
40+
// tags) so relations can still be re-resolved after a remount, such as a
41+
// `react-freeze` unfreeze (see #4238).
42+
gesture.relationsSnapshot ??= {
43+
simultaneousWith: gesture.config.simultaneousWith,
44+
requireToFail: gesture.config.requireToFail,
45+
};
46+
3447
const newConfig = { ...gesture.config };
3548

3649
// No need to extend `blocksHandlers` here, because it's not changed in composition.
3750
// The same effect is achieved by reversing the order of 2 gestures in `Exclusive`
3851
newConfig.simultaneousWith = extendRelation(
39-
newConfig.simultaneousWith,
52+
gesture.relationsSnapshot.simultaneousWith,
4053
simultaneousGestures
4154
);
4255
newConfig.requireToFail = extendRelation(
43-
newConfig.requireToFail,
56+
gesture.relationsSnapshot.requireToFail,
4457
requireGesturesToFail
4558
);
4659

0 commit comments

Comments
 (0)