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

Open
#4,504 6 comments 0 reactions 1 assignee View on GitHub

@kacperzolkiewski is already working on this.

Since Sep 16, 2026.

platform:android repro-provided
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
  1. React removes the nested <Screen> children → screen.container = null; performUpdates is posted to the UI queue.
  2. In the same frame, the nested ScreenContainer view is detached (its host route is being torn down).
  3. onDetachedFromWindowremoveMyFragments → the filter screen.container === this is now falsethe fragment is skipped. isAttached is set to false.
  4. The posted performUpdates finally runs, but bails out immediately:
    // ScreenContainer.kt:371
    fun performUpdates() {
        if (!needsUpdate || !isAttached || fragmentManager == null || fragmentManager?.isDestroyed == true) {
            return   // ← removal is abandoned permanently, not retried
        }
    
    The deferred removal is now permanently abandoned, and the orphan stays in the parent screen's childFragmentManager with mContainerId pointing at a container that no longer exists.
  5. 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 onScreenChangedrunOnUiQueueThread 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.

  1. In an expo-router app, declare a root-level modal route with freezeOnBlur: true.
  2. Have that route's _layout render a nested <Stack>.
  3. Navigate into the modal route, then close it with router.dismissAll() immediately followed by router.replace('/...'), i.e. two structural navigation ops landing in a single commit.
  4. Repeat. Occasionally the nested container's fragment is orphaned; the crash surfaces later, when that parent screen's childFragmentManager next moves its fragments to their expected state.

Two things have to line up for the leak to occur:

  1. Within a single Fabric mount commit, the nested <Screen> removal (which nulls screen.container) has to run before the container's detach (which runs removeMyFragments). If the detach happens first, the cleanup works correctly. Both orderings are legal for Fabric's mount instructions and neither is controllable from JS.
  2. The leak is silent. Nothing throws at the moment the fragment is orphaned — the crash only surfaces later, when that parent screen's childFragmentManager next 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
  1. 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.fragmentWrapper membership, so removeMyFragments still recognises screens whose container has already been nulled.
  2. Or clear screen.container at the same time the fragment is actually removed (in onUpdate), not eagerly in removeScreenAt.
  3. Or make performUpdates' !isAttached early-return not lose pending removals — currently needsUpdate has 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.