From 81217362ad658bfeff1cd2a11ac74b9f40ad3eb1 Mon Sep 17 00:00:00 2001 From: ghenry22 Date: Wed, 8 Apr 2026 23:53:23 +1000 Subject: [PATCH] fix(Android): null Screen.fragmentWrapper on dismiss to fix Fabric leak 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: https://github.com/software-mansion/react-native-screens/issues/3755 --- .../java/com/swmansion/rnscreens/ScreenFragment.kt | 13 ++++++++++++- .../com/swmansion/rnscreens/ScreenViewManager.kt | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt index 65c6e304da..b6af7755ef 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt @@ -316,7 +316,8 @@ open class ScreenFragment : override fun onDestroy() { super.onDestroy() val container = screen.container - if (container == null || !container.hasScreen(this.screen.fragmentWrapper)) { + val isScreenDismissed = container == null || !container.hasScreen(this.screen.fragmentWrapper) + if (isScreenDismissed) { // we only send dismissed even when the screen has been removed from its container val screenContext = screen.context if (screenContext is ReactContext) { @@ -325,6 +326,16 @@ open class ScreenFragment : .getEventDispatcherForReactTag(screenContext, screen.id) ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) } + // Break the Screen->fragmentWrapper retain cycle so Fabric's + // SurfaceMountingManager.mTagToViewState doesn't keep the destroyed + // ScreenFragment alive. Gated on isScreenDismissed since onDestroy + // can also fire in non-dismissal lifecycle paths where the wrapper + // must remain reachable. Identity guard avoids clobbering a wrapper + // that has been re-assigned to a different fragment. + // See: https://github.com/software-mansion/react-native-screens/issues/3755 + if (this.screen.fragmentWrapper === this) { + this.screen.fragmentWrapper = null + } } childScreenContainers.clear() } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt index 2987072537..c0664540ff 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt @@ -95,6 +95,19 @@ open class ScreenViewManager : view.onFinalizePropsUpdate() } + // Belt-and-braces fragmentWrapper cleanup, in case Fabric drops the view + // without ScreenFragment.onDestroy firing on schedule. Breaks the + // Screen->fragmentWrapper retain cycle so SurfaceMountingManager doesn't + // keep the destroyed fragment alive. Guarded so we only null when no + // fragment is currently attached. + // See: https://github.com/software-mansion/react-native-screens/issues/3755 + override fun onDropViewInstance(view: Screen) { + if (view.fragmentWrapper?.fragment?.isAdded != true) { + view.fragmentWrapper = null + } + super.onDropViewInstance(view) + } + fun setActivityState( view: Screen, activityState: Int,