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)

Aperta
#57,198 0 commenti 11 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Lingua principale
C++
Stelle
127k
Fork
25.3k
Merge medio
1g 23h
PR unite (30g)
4

Descrizione

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.

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia clonando la riproduzione collegata ed eseguendo yarn android; ispeziona App.tsx, src/screens/ScreenTwo.tsx e src/components/ComplexButton.tsx mentre ripeti la navigazione Home–Screen Two. Confronta dumpsys meminfo e le letture del live heap di Hermes dopo ogni ciclo; il lavoro è completato quando la schermata smontata non causa più una crescita lineare della memoria JS e nativa.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
android, typescript
Ambito
mobile, performance
Tipo di issue
Bug
Difficoltà
5/5
Tempo stimato
Più di una settimana
Stato di attività
Tranquilla
Chiarezza
Da chiarire
Idoneità per principianti
30/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.