Fabric shadow subtrees of unmounted components using useSharedValue/useAnimatedStyle stay retained (native heap grows per screen visit) — reproduced on 4.5.1, 4.5.5 and 4.6.0
Description
Components that hold a useSharedValue and drive an Animated.View style through useAnimatedStyle keep their Fabric shadow subtree alive after they unmount. In an app with such a component in every list row, every visit to the screen adds the rows' shadow trees to the native heap and nothing releases them until the process dies. We first measured this on 4.5.1 (about 25 MB per visit with roughly forty rows), replaced the component with React Native's Animated API as a workaround, and have now re-measured the original component against 4.5.5 / worklets 0.10.4 and 4.6.0 / worklets 0.12.2: the retention is unchanged.
Steps to reproduce
- A pressable row component:
const AnimatedPressableBase = Animated.createAnimatedComponent(Pressable);
export function AnimatedPressable({ children, style, pressScale = 0.97, pressOpacity = 0.85, onPressIn, onPressOut, ...rest }) {
const pressed = useSharedValue(0);
const animStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 - (1 - pressScale) * pressed.value }],
opacity: 1 - (1 - pressOpacity) * pressed.value,
}));
return (
<AnimatedPressableBase
{...rest}
onPressIn={(e) => { pressed.value = withSpring(1); onPressIn?.(e); }}
onPressOut={(e) => { pressed.value = withSpring(0); onPressOut?.(e); }}
style={[style, animStyle]}
>
{children}
</AnimatedPressableBase>
);
}- Render it as the row of a list screen (a
FlashListwith about ten visible rows plus a few cards in a header is enough) behind a tab or a route that unmounts and remounts on navigation. - On a Release build on a physical Android device, navigate Home → list screen → Home repeatedly by deep link and read the native heap after each round trip:
adb shell dumpsys meminfo <pid> | grep 'Native Heap' # HeapAlloc columnWe drive this with a script: cold start on Home, then am start -a android.intent.action.VIEW -d "<scheme>:///<route>" alternating Home and the list route, five seconds settle, dumpsys meminfo per round trip, and compare the floor of the last three round trips against the first.
Expected
The native heap plateaus after the first visit (the fixed component using react-native's Animated API with the native driver plateaus at +4 to 5 MB over six round trips on the same screen).
Actual
Native HeapAlloc (dumpsys meminfo, KB / 1024) after each Home ↔ list round trip, Pixel 8a, Release build, React Native 0.86.3, Expo SDK 57, new architecture, same JS code in all three runs (only the Reanimated/worklets pair differs):
| Reanimated / worklets | RT1 | RT2 | RT3 | RT4 | RT5 | RT6 | floor(RT4–6) − RT1 |
|---|---|---|---|---|---|---|---|
| 4.5.1 / 0.10.1 | 86.3 MB | 103.5 | 107.5 | 102.4 | 114.5 | 105.7 | +16.1 MB |
| 4.5.5 / 0.10.4 | 86.7 MB | 104.0 | 109.3 | 114.3 | 118.6 | 119.4 | +27.6 MB |
| 4.6.0 / 0.12.2 | 86.2 MB | 98.1 | 99.4 | 104.3 | 110.0 | 111.5 | +18.1 MB |
Same screen, rows on RN Animated (no Reanimated in rows), 4.5.1 |
81.8 MB | 97.8 | 92.1 | 86.3 | 96.3 | — | +4.5 MB (5 round trips) |
Allocation attribution on 4.5.1 (malloc-debug wrap.sh with backtrace and record_allocs, size-band backtraces) pointed at Fabric ShadowNode instances retained through the Reanimated animated-props registry after the owning views were unmounted.
Platform
Android (Pixel 8a, Android 16), React Native 0.86.3, Expo SDK 57.0.20, new architecture, Hermes. iOS shows the same growth pattern (Jetsam terminations after roughly thirty visits on an iPhone 13 Pro with the 4.5.1 build before the workaround).
Workaround
Move per-row press feedback to react-native's Animated API with useNativeDriver: true; the retention disappears (last row of the table).
Source: software-mansion/react-native-reanimated