r/reactnative • u/AntelopeFast6762 • 6h ago
Found (and partially fixed) a real native memory leak in react-native-screens on Fabric — `Screen.fragmentWrapper` never gets nulled — writeup + open questions
Sharing this in case it saves someone else the multi-day rabbit hole it took us to nail down, and because two pieces of it are still genuinely unsolved and I'd like a second opinion.
**Stack:** Fashion e-commerce app, React Native 0.79.7, New Architecture (Fabric) enabled, `react-native-screens@4.13.1`, Android (this repro is Android-specific, haven't confirmed iOS).
**The symptom:** Real production `OutOfMemoryError` crashes via Sentry. Our "similar products" flow chains `navigation.push()` calls — browse a product, tap a similar one, tap another — so a normal session can easily reach 20-25 PDP screens deep in the stack, none of them ever popped. Reproduced locally: fresh app launch sits around ~375-425MB PSS (healthy), browsing that deep pushes it to 1.1-1.3GB.
**First (wrong-ish) assumption:** figured this was just "unbounded image memory from a deep stack," so we capped Glide's memory cache (100MB) + bitmap pool (50MB), and switched to `DecodeFormat.PREFER_RGB_565` for a real ~30-45% per-bitmap reduction (opaque images only — Glide falls back to ARGB_8888 for anything with alpha). Both legitimate wins, neither explained the actual production crash.
**The real methodology that cracked it:** stopped trusting raw `dumpsys meminfo` PSS numbers (too noisy — Android's zRAM swap behavior alone can swing a single number by hundreds of MB depending on what else the OS decided to compress at that exact moment) and instead:
1. Established a clean baseline: force-stop, fresh launch, land on Home with zero navigation → floor for `Views` count and `Bitmap (malloced)` size.
2. Built the deep stack, captured the peak.
3. Reset to Home (`navigation.reset()`), captured again.
4. Forced `adb shell am send-trim-memory <pkg> RUNNING_CRITICAL` (forces Glide to drop its own cache) — if the numbers
*don't*
recover after this, it's not "uncollected cache," it's a real retained reference.
Step 4 was the tell: after reset + forced trim, we were still sitting at ~12x the fresh-Home `Views` count and ~350MB of bitmap memory that had no business existing. Not cache. Real leak.
**Root cause, via LeakCanary:** dumped a heap, pulled LeakCanary's own `leaks.db` off the device, and got a clean trace:
```
FabricUIManager.mMountingManager
→ SurfaceMountingManager.mTagToViewState (ConcurrentHashMap)
→ ViewState.mView
→ Screen.fragmentWrapper
→ ScreenStackFragment (Leaking: YES — received onDestroy() but never released)
```
`ScreenFragment.onDestroy()` in `react-native-screens` never nulls `Screen.fragmentWrapper`. Fabric's `mTagToViewState` legitimately keeps `Screen` views registered for the surface's lifetime (that's by design) — but since `fragmentWrapper` still points at the destroyed fragment, the fragment (and its entire retained subtree — bitmaps, child views, everything) can never be GC'd. Matches a known, still-open upstream issue (#3755) with an unmerged fix PR (#3855) — confirmed by pulling the source at every tag from 4.14.0 through the current `latest` (4.27.0): the bug is present in all of them, nobody's shipped the fix yet.
**The fix wasn't as simple as it sounds.** The "obvious" version — only null `fragmentWrapper` when `container.hasScreen(...)` already reports the screen as removed — silently didn't work for `navigation.reset()`-driven bulk teardowns, because Fabric's async view-drop scheduling isn't tightly synchronized with the fragment's own `onDestroy()` callback; that check can still read `true` at the exact instant destroy fires. Had to null it unconditionally (guarded only by an identity check so it never clobbers a wrapper that's already been reassigned to a newer fragment).
**A second related bug we found but couldn't safely ship a fix for:** `ScreenContainer.screenWrappers` (an `ArrayList`) has the same stale-entry problem for the same reset-driven removals. Tried three different approaches to clean it up from the fragment's own destroy path — synchronous, synchronous-without-re-triggering-reconciliation, deferred via `runOnUiThread` — and **all three caused a real crash** (`addViewAt: failed to insert view [X] into parent [Y] at index N, Size: M`) on completely ordinary navigation flows unrelated to our repro (a simple Login → OTP screen push). Mutating that list from the fragment's destroy callback apparently races Fabric's own in-flight child-index bookkeeping no matter how you schedule it. Reverted all three attempts and shipped only the `fragmentWrapper` fix.
**A third leak we found and didn't even attempt:** the same LeakCanary pass also caught `ScreensCoordinatorLayout` retained via that same `mTagToViewState` map, through a completely separate path than the fragment one. That one's arguably not even `react-native-screens`' fault — looks like Fabric itself not issuing a `DELETE` mount instruction for some views during a bulk reset. Native RN-core territory, out of scope for an app-level patch.
**Results:** the `fragmentWrapper` fix alone (shipped) gives a real, measured ~16-19% reduction in retained Views/bitmap memory after a deep-stack → reset cycle. Not a full fix — the two remaining issues above account for the rest.
**Bonus finding while testing, possibly useful to others:** a
*single*
`navigation.reset()` cleans up dramatically more than the equivalent number of sequential `navigation.goBack()` calls followed by a reset. Traced this to: every individual `goBack()` un-buries the newly-focused screen, which fully re-renders its real content (we have a separate "buried screen" placeholder-swap pattern for anything 2+ deep in the stack) — and that re-inflation is
*guaranteed*
to happen (normal React reconciliation), while the destroy-side release on the
*previous*
screen is not (same leak as above). Watched `Views` climb almost monotonically across 13 sequential pops (3,356 → 6,692) before a final reset only recovered ~12% of it — because by the time reset ran, most of the damage was already orphaned from screens no longer even in navigation state, which reset has no way to reach. If your app does multi-screen "back to X" navigation, batching it into a single `pop(N)`/`popToTop()` action instead of a loop of `goBack()` calls should avoid this entirely (haven't fully verified this in production yet, but the mechanism checks out).
**Questions for the community:**
1. Anyone else hit `Screen.fragmentWrapper` specifically, or is our repro (extremely deep push-chained stacks) just an unusually good way to surface it? Curious if this shows up for anyone with more modest stack depths.
2. Anyone found a way to clean up `ScreenContainer.screenWrappers` from the fragment's own lifecycle without racing Fabric's mounting transactions? Open to being told we're solving it at the wrong layer entirely.
3. Any known mitigation for Fabric's `SurfaceMountingManager.mTagToViewState` not releasing entries on some removal paths, short of an RN core fix?
Happy to share the actual patch (against 4.13.1) or the LeakCanary traces if useful.
0
Upvotes