Skip to content

fix(Android): null Screen.fragmentWrapper on dismiss to fix Fabric retain cycle#3855

Open
ghenry22 wants to merge 1 commit intosoftware-mansion:mainfrom
ghenry22:fix/android-fragmentwrapper-leak
Open

fix(Android): null Screen.fragmentWrapper on dismiss to fix Fabric retain cycle#3855
ghenry22 wants to merge 1 commit intosoftware-mansion:mainfrom
ghenry22:fix/android-fragmentwrapper-leak

Conversation

@ghenry22
Copy link
Copy Markdown

@ghenry22 ghenry22 commented Apr 8, 2026

Description

Closes #3755.

On Fabric, SurfaceMountingManager.mTagToViewState keeps Screen views alive for the lifetime of the surface. Because Screen.fragmentWrapper still points to the destroyed ScreenFragment after dismissal, the fragment — and everything it transitively retains — can never be GC'd. The reporter on #3755 measured ~1.2 MB per back-nav cycle on a Galaxy A7 / API 29 with rnscreens 4.20–4.24.

The fix nulls Screen.fragmentWrapper in ScreenFragment.onDestroy, gated on the existing isScreenDismissed discriminator that onDestroy already uses to decide whether to dispatch ScreenDismissedEvent:

val isScreenDismissed = container == null || !container.hasScreen(this.screen.fragmentWrapper)

This gating matters because onDestroy can fire in lifecycle paths where the screen has not actually been removed from its container. The unconditional null proposed in #3755 crashes in those paths with IllegalStateException: Parent Screen does not have its Fragment attached at ScreenContainer.setupFragmentManager. Gating on the existing hasScreen() check leaves the back-reference intact in non-dismissal paths and only breaks the cycle when the screen is actually gone.

A second hunk in ScreenViewManager.onDropViewInstance does the same cleanup as a fallback for the case where Fabric drops the view without onDestroy firing on schedule, guarded by view.fragmentWrapper?.fragment?.isAdded != true so it only nulls when no live fragment is attached.

Library precedent for both pieces:

  • ScreenContainer.parentScreenWrapper is already nulled in onDetachedFromWindow().
  • ScreenStackHeaderConfigViewManager.onDropViewInstance already calls view.destroy().

Changes

  • ScreenFragment.kt — hoist the existing dismissal check into a named isScreenDismissed local, then null screen.fragmentWrapper inside that branch with an === this identity guard.
  • ScreenViewManager.kt — add an onDropViewInstance(view: Screen) override that nulls view.fragmentWrapper when no fragment is currently attached, then calls super.

Test plan

Validation done:

  • Built against RN 0.83 / Fabric on a real device with rnscreens 4.24.0 + these two hunks applied as a patch-package patch.
  • Rapid push/pop on a native stack — no crashes, no blank screens.
  • Push a screen → push another on top → pop back through both — the underlying screen renders correctly (this is the case the unconditional null breaks).
  • Open and dismiss a modal/sheet-presented screen rapidly — no regressions.
  • Cross-stack navigation between a tabbed root and a presented stack — no JS↔native state divergence.

Reproducer for the leak (from #3755):

  1. Push any screen onto a native stack.
  2. Press back to pop it.
  3. Repeat several times.
  4. Take a heap dump (Android Studio Profiler or LeakCanary 2.14+).
  5. Before this fix: retained ScreenFragment instances appear with the chain Screen.fragmentWrapper → ScreenFragment (and ScreenContainer.parentScreenWrapper → ScreenStackFragment for stack fragments). The reporter on [Android][Fabric] Screen.fragmentWrapper retains destroyed ScreenFragment via Fabric's mTagToViewState #3755 measured ~1.2 MB per cycle on a Galaxy A7 / API 29.
  6. After this fix: the chain is broken at Screen.fragmentWrapper and the fragments are GC'd on the next collection.

Failure mode this fix avoids:

The unconditional screen.fragmentWrapper = null proposed in #3755 produces:

java.lang.IllegalStateException: Parent Screen does not have its Fragment attached
    at com.swmansion.rnscreens.ScreenContainer.setupFragmentManager

…on back-nav from any pushed screen, because onDestroy fires for screens whose wrapper is still needed by the in-flight transaction. The discriminator-gating in this PR avoids that path entirely — the hasScreen() check returns true for those screens, and we don't touch the wrapper.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change. (N/A — pure memory fix, no visual change.)
  • For API changes, updated relevant public types. (N/A — no public API change.)
  • Ensured that CI passes. (Pending CI run.)

Fabric's SurfaceMountingManager.mTagToViewState keeps Screen views alive
for the lifetime of the surface. Because Screen.fragmentWrapper still
points to the destroyed ScreenFragment after dismissal, the fragment
and everything it transitively retains can never be GC'd.

ScreenFragment.onDestroy now nulls the back-reference, gated on the
existing isScreenDismissed discriminator so it only fires when the
screen has actually been removed from its container. ScreenViewManager
.onDropViewInstance does the same as a belt-and-braces fallback for
the case where Fabric drops the view without onDestroy firing on
schedule, guarded so it only nulls when no fragment is currently
attached.

See: software-mansion#3755
@kkafar
Copy link
Copy Markdown
Member

kkafar commented Apr 9, 2026

Thanks for the report. We recently did an investigation & AFAIK there should not be a leak - we'll investigate this nonetheless. cc @t0maboro can I ask you to take a look here?

@kkafar kkafar requested a review from t0maboro April 9, 2026 08:20
@kkafar kkafar assigned kkafar and t0maboro and unassigned kkafar Apr 9, 2026
@t0maboro
Copy link
Copy Markdown
Contributor

t0maboro commented Apr 9, 2026

EDIT: #3867 - noticed another issue with headerShown: true/false

@t0maboro
Copy link
Copy Markdown
Contributor

t0maboro commented Apr 9, 2026

Also, @ghenry22 would you be able to prepare and add to this PR a new example that shows the difference between main and this PR - it'd be really helpful for regression testing in the future

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android][Fabric] Screen.fragmentWrapper retains destroyed ScreenFragment via Fabric's mTagToViewState

3 participants