Skip to content

fix(android): stop the FragmentManager driving fragments with a dead entry - #11352

Open
edusperoni wants to merge 1 commit into
mainfrom
fix/android-stale-fragment-teardown
Open

fix(android): stop the FragmentManager driving fragments with a dead entry#11352
edusperoni wants to merge 1 commit into
mainfrom
fix/android-stale-fragment-teardown

Conversation

@edusperoni

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

A FragmentClass can outlive the BackstackEntry it is bound to, and the FragmentManager then drives it through the lifecycle against that dead entry. The reported symptom is a fatal:

com.tns.NativeScriptException: Calling js method onCreateView failed
Error: fragment0[0]<null>.onCreateView: entry has no resolvedPage
	at com.tns.FragmentClass.onCreateView(FragmentClass.java:55)
	at androidx.fragment.app.Fragment.performCreateView(Fragment.java:3119)
	at androidx.fragment.app.FragmentStateManager.createView(FragmentStateManager.java:577)
	at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:286)
	at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:2211)
	at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:2106)
	at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:2049)

Note <null> in the fragment's toString: the fragment's entry is intact, only resolvedPage is gone.

How the fragment gets stranded. Navigation only evicts fragments via transaction.replace(this.containerViewId, ...), which removes added fragments in that container. Frame._removeEntry then clears resolvedPage (FrameBase._removeEntry) and drops entry.fragment, but never tells the FragmentManager anything — it assumes the replace already did. That assumption breaks for any fragment whose container is no longer the frame's current container: leftovers from an activity recreation, a frame/root-view reset, or a navigation that was interrupted mid-flight. Such a fragment stays mAdded, so moveToExpectedState() keeps promoting it, and it gets onCreateView again after its entry has been discarded.

Why it is fatal rather than a no-op. Every callback on that path either reported the condition through Trace.error — which routes to DefaultErrorHandler, rethrows, and surfaces as an uncaught exception across the JNI boundary — or dereferenced the missing page directly:

  • onCreateView — fixed in fix(android): don't crash in Fragment.onCreateView on stale-fragment race #11264, but that only moved the crash one step later, since the fragment continues to…
  • onResumethis.entry.resolvedPage.frame throws a TypeError on the very same stale fragment
  • onDestroyTrace.error on a missing entry, which is exactly what a discarded fragment hits on teardown
  • onPause / onDestroyViewthis.frame.nativeViewProtected with no frame
  • findPageForFragmentthrow new Error('Could not find a page for <tag>') for a restored fragment no live entry claims

This correlates strongly with memory pressure: the tighter memory gets, the more the OS destroys and recreates activities, which is what strands the fragments in the first place. It was reported from production with 300+ occurrences on low-RAM (4 GB) devices, mostly on resume after the app had been idle.

What is the new behavior?

The stale fragment is discarded instead of taking the app down, and it stops being stale in the first place:

  • Frame._removeEntry — when the discarded entry still has a fragment, detach the fragment's callbacks (entry/frame) and remove it from its FragmentManager if it is still added. This is the actual leak fix: the guard only fires for fragments that are still added while their entry is being discarded, i.e. exactly the orphans. In normal forward/back/clearHistory/backstackVisible: false navigation the outgoing fragment has already been evicted by the replace before setCurrent runs, so isAdded() is false and nothing changes.
  • findPageForFragment — widen the entry lookup through the (previously unused) _findEntryForTag, which also searches the backstack and the navigation queue, so fragments Android restored from those are matched rather than declared orphans. When there genuinely is no entry, log and remove the fragment instead of throwing.
  • onDestroy / onResume / onPause / onDestroyView — treat a missing entry, page or frame as the recoverable state it is. onResume in particular had to be guarded, or the onCreateView fix from fix(android): don't crash in Fragment.onCreateView on stale-fragment race #11264 just relocates the crash.

Removals are always committed with commitAllowingStateLoss(): callers can be inside a FragmentManager transaction (findPageForFragment runs from onCreate, _removeEntry from a transition listener), where commitNow throws "FragmentManager is already executing transactions".

Also considered and left out: extending the restored-fragment cleanup in ActivityCallbacksImplementation.onCreate (which deliberately skips fragment* tags) to drop unclaimed NativeScript fragments. Having each orphan remove itself in its own onCreate is ordering-independent and also covers child FragmentManagers, which that cleanup does not walk.

No unit tests: packages/core specs cover parser/pure-logic modules only and nothing platform-specific, and these are FragmentManager lifecycle paths. Verification is on device — activity destroy/recreate (Don't keep activities) with a navigation in flight, plus tabs/bottom-navigation nested frames on resume.

…entry

A fragment can outlive the BackstackEntry it is bound to. Navigation only
evicts fragments through transaction.replace(containerViewId, ...), so any
fragment whose container no longer matches the frame's current container -
left over from an activity recreation, a frame reset, or an interrupted
navigation - stays added to the FragmentManager. _removeEntry then clears
resolvedPage and drops entry.fragment without telling the FragmentManager,
and the next transaction happily drives that fragment back through
onCreateView.

Every callback on that path either reported the condition with Trace.error
- which routes to the error handler, rethrows, and becomes a fatal
exception across the JNI boundary - or dereferenced the missing page
directly, so the stale fragment took the app down instead of being
discarded.

- _removeEntry: detach the fragment's callbacks and remove it from the
  FragmentManager when it is still added, so it can neither be driven
  again nor resurrect the torn down page
- findPageForFragment: discard an unclaimed restored fragment instead of
  throwing, and widen the entry lookup through _findEntryForTag so
  fragments restored from the backstack or the navigation queue are
  matched instead of treated as orphans
- onDestroy: report a missing entry without throwing
- onResume: bail out when the entry or page is gone rather than reading
  entry.resolvedPage.frame
- onPause/onDestroyView: tolerate a missing frame
@nx-cloud

nx-cloud Bot commented Aug 17, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit f67ee37

Command Status Duration Result
nx test apps-automated -c=ios ✅ Succeeded 2m 55s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-17 15:10:07 UTC

@edusperoni
edusperoni marked this pull request as ready for review August 17, 2026 16:33
@edusperoni
edusperoni requested a balanced review from Copilot August 17, 2026 16:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents Android’s FragmentManager from driving orphaned fragments after their navigation entries are discarded.

Changes:

  • Removes still-added fragments when entries are discarded.
  • Expands restored-fragment lookup across backstack and navigation queues.
  • Adds null-safe lifecycle handling for stale fragments.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/core/ui/frame/index.android.ts Detaches and removes fragments for discarded entries.
packages/core/ui/frame/frame-helper-for-android.ts Adds orphan cleanup and lifecycle guards.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

} else {
// Android also restores fragments that were only in the backstack or still queued, so
// widen the lookup before treating this fragment as an orphan.
entry = frame._findEntryForTag(fragmentTag);
Comment on lines +62 to +63
Trace.write(`Could not find a page for ${fragmentTag}. Discarding orphaned fragment.`, Trace.categories.NativeLifecycle, Trace.messageType.error);
removeFragmentIfAdded(fragment);
Comment on lines +92 to +93
callbacks.entry = null;
callbacks.frame = null;
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.

2 participants