react / react/react-native

Memory not reclaimed after unmounting a screen with many components, across repeated navigations (New Architecture / Fabric, 0.86.0-rc.3)

Ouverte
#57,198 0 commentaires 11 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

Langage dominant
C++
Étoiles
127k
Forks
25.3k
Merge moyen
1 j 23 h
PR mergées (30 j)
4

Description

Description

Repeatedly mounting and unmounting a screen that renders many components (1000 Pressable-based rows inside a ScrollView) leaks memory that is never reclaimed, even after forcing GC and returning to a trivial screen.

Each Home → HeavyScreen → Home round-trip permanently retains a constant amount of JS heap and a much larger amount of native memory. Growth is linear and does not plateau, so a screen that is navigated into and out of repeatedly will grow process memory without bound.

The unmounted screen has no app-level retainers (no listeners, timers, subscriptions, refs, or module-level mutation; all callbacks are stable useCallbacks; rows are React.memo), so the retained tree should be fully collectible after unmount.

React Native version

react-native: 0.86.0-rc.3
react: 19.2.3
  • Architecture: New Architecture (Fabric) + Bridgeless
  • JS engine: Hermes
  • Platform: Android (emulator, API 36, arm64-v8a)
  • Build variant: debug

Reproduction repository

👉 https://github.com/fardad-dev/react-native-memory-leak — a fresh RN 0.86.0-rc.3

git clone https://github.com/fardad-dev/react-native-memory-leak
cd react-native-memory-leak
yarn
yarn android   # New Architecture + Hermes, debug

Key files: App.tsx (manual useState navigation), src/screens/ScreenTwo.tsx (renders 1000 ComplexButtons in a ScrollView), src/components/ComplexButton.tsx (the Pressable-based row).

Steps to reproduce

  1. Run the repo above on an Android device/emulator (the app opens on a trivial Home screen).
  2. Tap Open Screen Two → to mount ScreenTwo (1000 components), then ← Back to Home to fully unmount it.
  3. Repeat Home → Screen Two → Home ~10 times.
  4. After each return to Home, measure memory:
    • Native (no debugger needed): adb shell dumpsys meminfo com.awesomeprojectrcTOTAL PSS / Native Heap.
    • JS heap (optional): force GC and read Hermes live-heap-after-GC:
      HermesInternal.getInstrumentedStats().js_allocatedBytes.

A condensed single-file version of the repro is also included at the bottom of this report.

Expected

After returning to the trivial Home screen and forcing GC, live memory returns to ~the first-visit baseline. The unmounted Heavy screen's component instances are collected.

Actual

Memory grows by a constant amount on every round-trip and never comes back down — even though measurements are taken on the identical empty Home screen, after 4× globalThis.gc().

Live JS heap (js_allocatedBytes, after forced GC, measured at Home):

At Home after… Live JS heap (bytes) Δ vs previous
baseline 1,653,368
cycle 1 2,602,032 +948,664 (incl. one-time init)
cycle 2 3,168,392 +566,360
cycle 3 3,734,328 +565,936
cycle 4 4,300,264 +565,936
cycle 5 4,866,200 +565,936
cycle 6 5,432,304 +566,104
cycle 7 5,998,240 +565,936
cycle 8 6,564,176 +565,936
cycle 9 7,130,112 +565,936
cycle 10 7,696,304 +566,192

~565,936 bytes (~553 KB) retained per round-trip, constant to the byte across 9 cycles (~566 bytes × 1000 rows). js_externalBytes stays flat (1,116) and Hermes committed heap is only 32 MB, so this is not Hermes heap fragmentation.

Native memory (dumpsys meminfo, same empty Home screen, same PID):

Baseline Home After 10 cycles Δ
TOTAL PSS 158,003 KB 641,221 KB +472 MB
Native Heap 64,428 KB 461,736 KB +388 MB
Java Heap 11,408 KB 67,404 KB +55 MB
TOTAL RSS 271,048 KB 754,928 KB +484 MB

~47 MB total PSS leaked per round-trip (~39 MB of it native heap). The JS retention drags a much larger Fabric/native footprint (shadow nodes / host views) with it.

What was ruled out

  • Not uncollected garbage: globalThis.gc() is called 4× before each reading (js_numGCs climbed 0 → 1241 over the run); these are live, post-collection bytes.
  • Not measurement screen state: every measurement is on the identical, fully-unmounted Home screen.
  • Not app-level retention: the repro has no listeners/timers/subscriptions/global refs; onPress/renderItem/keyExtractor are stable useCallbacks; rows are React.memo.

Condensed single-file reproduction

Self-contained App.tsx (same behavior as the linked repo)
/**
 * Minimal reproduction: memory not reclaimed after unmounting a screen
 * with many components, across repeated navigations (New Architecture / Fabric).
 *
 * Manual navigation: a single piece of state decides which screen is mounted,
 * so going "back" fully unmounts the heavy screen.
 */
import React, { Fragment } from 'react';
import {
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  View,
  type GestureResponderEvent,
} from 'react-native';

const ITEM_COUNT = 1000;

type Item = { id: string; index: number; label: string };
const DATA: Item[] = Array.from({ length: ITEM_COUNT }, (_, index) => ({
  id: `two-${index}`,
  index,
  label: `Screen Two Item ${index}`,
}));

/** A deliberately multi-layered row so 1000 of them is a real amount of tree. */
function ComplexButtonBase({
  index,
  label,
  onPress,
}: {
  index: number;
  label: string;
  onPress: (index: number) => void;
}) {
  const handlePress = React.useCallback(
    (_e: GestureResponderEvent) => onPress(index),
    [index, onPress],
  );
  const hue = (index * 37) % 360;
  const progress = `${(index * 13) % 100}%` as const;
  const initials = label.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
  return (
    <Pressable onPress={handlePress} style={({ pressed }) => [styles.button, pressed && styles.pressed]}>
      <View style={[styles.badge, { backgroundColor: `hsl(${hue}, 70%, 55%)` }]}>
        <Text style={styles.badgeText}>{initials}</Text>
      </View>
      <View style={styles.body}>
        <Text style={styles.title} numberOfLines={1}>{label}</Text>
        <Text style={styles.subtitle} numberOfLines={1}>Tap to interact with item #{index}</Text>
        <View style={styles.track}><View style={[styles.fill, { width: progress }]} /></View>
      </View>
      <View style={styles.meta}><Text style={styles.metaValue}>#{index}</Text></View>
    </Pressable>
  );
}
const ComplexButton = React.memo(ComplexButtonBase);

function HeavyScreen({ onBack }: { onBack: () => void }) {
  const onPress = React.useCallback((i: number) => {}, []);
  return (
    <View style={styles.flex}>
      <Text style={styles.link} onPress={onBack}>← Back to Home</Text>
      <ScrollView>
        {DATA.map(item => (
          <Fragment key={item.id}>
            <ComplexButton index={item.index} label={item.label} onPress={onPress} />
          </Fragment>
        ))}
      </ScrollView>
    </View>
  );
}

function HomeScreen({ onOpen }: { onOpen: () => void }) {
  return (
    <View style={[styles.flex, styles.center]}>
      <Text style={styles.h1}>Home</Text>
      {/* Optional in-app measurement: works when the runtime exposes globalThis.gc */}
      <Text style={styles.link} onPress={measureLiveHeap}>Measure live JS heap (logcat)</Text>
      <Text style={styles.link} onPress={onOpen}>Open Heavy Screen →</Text>
    </View>
  );
}

/** Forces GC (if available) and logs Hermes live-heap-after-GC. */
function measureLiveHeap() {
  const g: any = globalThis as any;
  for (let k = 0; k < 4 && typeof g.gc === 'function'; k++) g.gc();
  const s = g.HermesInternal?.getInstrumentedStats?.();
  console.log('[leak] js_allocatedBytes =', s?.js_allocatedBytes, ' js_heapSize =', s?.js_heapSize);
}

export default function App() {
  const [heavy, setHeavy] = React.useState(false);
  return (
    <View style={[styles.flex, styles.top]}>
      {heavy ? (
        <HeavyScreen onBack={() => setHeavy(false)} />
      ) : (
        <HomeScreen onOpen={() => setHeavy(true)} />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  flex: { flex: 1 },
  top: { paddingTop: 50 },
  center: { alignItems: 'center', justifyContent: 'center' },
  h1: { fontSize: 28, fontWeight: '700', marginBottom: 24 },
  link: { fontSize: 17, fontWeight: '600', color: '#4c8bf5', paddingVertical: 12 },
  button: {
    flexDirection: 'row', alignItems: 'center', padding: 12, marginHorizontal: 12,
    marginVertical: 6, backgroundColor: '#fff', borderRadius: 14, borderWidth: 1, borderColor: '#e2e2e8',
  },
  pressed: { backgroundColor: '#f1f1f6' },
  badge: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginRight: 12 },
  badgeText: { color: '#fff', fontSize: 16, fontWeight: '700' },
  body: { flex: 1 },
  title: { fontSize: 15, fontWeight: '600', color: '#1c1c1e' },
  subtitle: { fontSize: 12, color: '#8e8e93', marginTop: 2 },
  track: { height: 4, borderRadius: 2, backgroundColor: '#ececf1', marginTop: 8, overflow: 'hidden' },
  fill: { height: 4, borderRadius: 2, backgroundColor: '#4c8bf5' },
  meta: { alignItems: 'flex-end', marginLeft: 12 },
  metaValue: { fontSize: 13, fontWeight: '600', color: '#3a3a3c' },
});

Environment note: the JS-heap numbers above were captured with globalThis.gc() available in the local build; the native dumpsys meminfo evidence does not depend on gc(), so the leak is observable without it.

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Commencez par cloner la reproduction liée et exécuter yarn android ; inspectez App.tsx, src/screens/ScreenTwo.tsx et src/components/ComplexButton.tsx tout en répétant la navigation Home–Screen Two. Comparez dumpsys meminfo et les mesures du live heap de Hermes après chaque cycle ; c’est terminé lorsque l’écran démonté n’entraîne plus de croissance linéaire de la mémoire JS et native.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
android, typescript
Domaine
mobile, performance
Type d'issue
Bug
Difficulté
5/5
Temps estimé
Plus d'une semaine
Activité
Calme
Clarté
À clarifier
Accessibilité débutants
30/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.