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)

Đang mở
#57,198 0 bình luận 11 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Ngôn ngữ chính
C++
Star
127k
Fork
25.3k
Merge trung bình
1 ngày 23 giờ
Pull request đã merge (30 ngày)
4

Mô tả

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.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu bằng cách clone bản tái hiện được liên kết và chạy yarn android; kiểm tra App.tsx, src/screens/ScreenTwo.tsx và src/components/ComplexButton.tsx trong khi lặp lại điều hướng Home–Screen Two. So sánh dumpsys meminfo và các giá trị đọc live heap của Hermes sau mỗi chu kỳ; được coi là hoàn tất khi screen đã unmount không còn gây ra mức tăng tuyến tính của bộ nhớ JS và native.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
android, typescript
Lĩnh vực
mobile, performance
Loại issue
Lỗi
Độ khó
5/5
Thời gian dự kiến
Hơn một tuần
Mức độ hoạt động
Ít trao đổi
Độ rõ ràng
Cần làm rõ
Mức phù hợp với người mới
30/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.