Commit 6ba9d63
authored
Fix gesture relations after
## 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>freeze (#4244)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
Lines changed: 6 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
| 68 | + | |
| 69 | + | |
72 | 70 | | |
73 | 71 | | |
74 | | - | |
75 | | - | |
76 | | - | |
| 72 | + | |
77 | 73 | | |
78 | 74 | | |
79 | | - | |
80 | | - | |
81 | | - | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
82 | 78 | | |
83 | 79 | | |
84 | 80 | | |
| |||
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
137 | 137 | | |
138 | 138 | | |
139 | 139 | | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
140 | 151 | | |
141 | 152 | | |
142 | 153 | | |
| |||
Lines changed: 15 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
34 | 47 | | |
35 | 48 | | |
36 | 49 | | |
37 | 50 | | |
38 | 51 | | |
39 | | - | |
| 52 | + | |
40 | 53 | | |
41 | 54 | | |
42 | 55 | | |
43 | | - | |
| 56 | + | |
44 | 57 | | |
45 | 58 | | |
46 | 59 | | |
| |||
0 commit comments