software-mansion / software-mansion/react-native-screens
[Android] Nested ScreenContainer leaks an orphaned fragment when a screen is removed in the same frame the container detaches → No view found for id
@kacperzolkiewski is already working on this.
Since Sep 16, 2026.
- Dominant language
- TypeScript
- Stars
- 3.7k
- Forks
- 714
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 71
Description
Filled in the issue-template sections below (the issue was originally opened via
gh, which bypassed the form — sorry about that). See Snack or a link to a repository for why there is no reproduction.
Description
ScreenContainer.removeMyFragments() — the cleanup that onDetachedFromWindow runs specifically to prevent "fragment manager will crash because it won't be able to find container view" — can silently skip the very fragments it is meant to remove.
The filter identifies fragments by fragment.screen.container === this:
// ScreenContainer.kt:283
private fun removeMyFragments(fragmentManager: FragmentManager) {
val transaction = fragmentManager.beginTransaction()
var hasFragments = false
for (fragment in fragmentManager.fragments) {
if (fragment is ScreenFragment && fragment.screen.container === this) { // ← here
transaction.remove(fragment)
hasFragments = true
}
}
if (hasFragments) transaction.commitNowAllowingStateLoss()
}
But removeScreenAt() clears that same back-reference synchronously, while deferring the actual fragment removal by a tick:
// ScreenContainer.kt:112
open fun removeScreenAt(index: Int) {
screenWrappers[index].screen.container = null // cleared immediately
screenWrappers.removeAt(index)
onScreenChanged() // fragment removal deferred to runOnUiQueueThread
}
removeScreenAt / removeAllScreens are called straight from ScreenContainerViewManager.removeViewAt / removeAllViews, i.e. during the mount commit in which React removes the nested <Screen> children.
So there is a window in which screen.container == null but the fragment is still added to the parent screen's childFragmentManager.
The leak
- React removes the nested
<Screen>children →screen.container = null;performUpdatesis posted to the UI queue. - In the same frame, the nested
ScreenContainerview is detached (its host route is being torn down). onDetachedFromWindow→removeMyFragments→ the filterscreen.container === thisis nowfalse→ the fragment is skipped.isAttachedis set tofalse.- The posted
performUpdatesfinally runs, but bails out immediately:
The deferred removal is now permanently abandoned, and the orphan stays in the parent screen's// ScreenContainer.kt:371 fun performUpdates() { if (!needsUpdate || !isAttached || fragmentManager == null || fragmentManager?.isDestroyed == true) { return // ← removal is abandoned permanently, not retried }childFragmentManagerwithmContainerIdpointing at a container that no longer exists. - Later, when that child FragmentManager moves its fragments to their expected state,
FragmentStateManager.createView()looks the container up and throws.
The !isAttached guard is what seals the leak — it makes the abandoned removal permanent rather than deferred.
Stack trace (production, deobfuscated)
java.lang.IllegalArgumentException: No view found for id 0x190 (unknown) for fragment a0{a6ac70} (…)
at androidx.fragment.app.FragmentStateManager.createView (FragmentStateManager.java:567)
at androidx.fragment.app.FragmentStateManager.moveToExpectedState (FragmentStateManager.java:286)
at androidx.fragment.app.FragmentStore.moveToExpectedState (FragmentStore.java:114)
at androidx.fragment.app.FragmentManager.moveToState (FragmentManager.java:1685)
at androidx.fragment.app.FragmentManager.dispatchStateChange (FragmentManager.java:3319)
at androidx.fragment.app.FragmentManager.dispatchViewCreated (FragmentManager.java:3230)
at androidx.fragment.app.Fragment.performViewCreated (Fragment.java:3153)
at androidx.fragment.app.FragmentStateManager.createView (FragmentStateManager.java:608)
at androidx.fragment.app.FragmentStateManager.moveToExpectedState (FragmentStateManager.java:286)
at androidx.fragment.app.FragmentManager.executeOpsTogether (FragmentManager.java:2214)
at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute (FragmentManager.java:2115)
at androidx.fragment.app.FragmentManager.execSingleAction (FragmentManager.java:2002)
at androidx.fragment.app.BackStackRecord.commitNowAllowingStateLoss (BackStackRecord.java:323)
at com.swmansion.rnscreens.ScreenStack.onUpdate (ScreenStack.kt:298)
at com.swmansion.rnscreens.ScreenContainer.performUpdates (ScreenContainer.kt:376)
at com.swmansion.rnscreens.ScreenContainer.onScreenChanged$lambda$8 (ScreenContainer.kt:359)
at android.os.Handler.handleCallback (Handler.java:958)
at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage (MessageQueueThreadHandler.kt:21)
Note the outer frame is also the deferred onScreenChanged → runOnUiQueueThread path, so the same one-frame deferral appears on both ends.
Steps to reproduce
This is a pure timing race, so these steps do not trigger it deterministically — see the next section.
- In an
expo-routerapp, declare a root-level modal route withfreezeOnBlur: true. - Have that route's
_layoutrender a nested<Stack>. - Navigate into the modal route, then close it with
router.dismissAll()immediately followed byrouter.replace('/...'), i.e. two structural navigation ops landing in a single commit. - Repeat. Occasionally the nested container's fragment is orphaned; the crash surfaces later, when that parent screen's
childFragmentManagernext moves its fragments to their expected state.
Two things have to line up for the leak to occur:
- Within a single Fabric mount commit, the nested
<Screen>removal (which nullsscreen.container) has to run before the container's detach (which runsremoveMyFragments). If the detach happens first, the cleanup works correctly. Both orderings are legal for Fabric's mount instructions and neither is controllable from JS. - The leak is silent. Nothing throws at the moment the fragment is orphaned — the crash only surfaces later, when that parent screen's
childFragmentManagernext moves its fragments to their expected state.
Snack or a link to a repository
Not available. I wasn't able to build a reproduction, for the two reasons above: the ordering in (1) isn't controllable from JS, and (2) means even a successful leak doesn't throw until a separate later trigger. A Snack can't help here either, since this needs a custom Android build to observe.
Because of that I reported it as a code-level analysis instead — the mechanism is pinned to specific lines in ScreenContainer.kt (283 / 112 / 371) and each step above is verifiable by reading the source, without needing to run anything.
I understand this doesn't meet the usual repro bar, so please close this if it isn't actionable in this form. If it is worth pursuing and you'd like a reproduction attempt, let me know what would be most useful — e.g. a minimal app instrumented to log when removeMyFragments skips a fragment, which would surface the leak itself rather than waiting for the crash.
Screens version
4.23.0
React Native version
0.83.6
Platforms
Android
JavaScript runtime
Hermes
Workflow
Expo (bare / prebuild, CNG)
Build type
Release build (production)
Device
Real device
Device model
samsung SM-A136S (Android 14)
Acknowledgements
Yes
Suggested fixes
- Don't identify the fragments by a back-reference that is cleared earlier than the fragments are removed. Track ownership on the container (e.g. the fragments it has added) or check
fragment.screen.fragmentWrappermembership, soremoveMyFragmentsstill recognises screens whosecontainerhas already been nulled. - Or clear
screen.containerat the same time the fragment is actually removed (inonUpdate), not eagerly inremoveScreenAt. - Or make
performUpdates'!isAttachedearly-return not lose pending removals — currentlyneedsUpdatehas already been consumed/blocked, so the work is dropped rather than retried on re-attach.
We're holding off on opening a fix PR for now, since we're not sure which of these directions is the right one. Option 2 in particular has a wider blast radius than it first looks — ScreenContainer.kt:401 already reads screen.container == null as a signal, so changing when it is cleared would affect that check as well.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.