From f67ee37eb830e3264cdb964dc90d4a5f46bdb093 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 17 Aug 2026 12:03:55 -0300 Subject: [PATCH] fix(android): stop the FragmentManager driving fragments with a dead 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 --- .../core/ui/frame/frame-helper-for-android.ts | 62 +++++++++++++++++-- packages/core/ui/frame/index.android.ts | 14 ++++- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/core/ui/frame/frame-helper-for-android.ts b/packages/core/ui/frame/frame-helper-for-android.ts index 093dac0163..35e2338d52 100644 --- a/packages/core/ui/frame/frame-helper-for-android.ts +++ b/packages/core/ui/frame/frame-helper-for-android.ts @@ -37,6 +37,10 @@ function findPageForFragment(fragment: androidx.fragment.app.Fragment, frame: Fr entry = current; } else if (executingContext && executingContext.entry && executingContext.entry.fragmentTag === fragmentTag) { entry = executingContext.entry; + } 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); } let page: Page; @@ -52,7 +56,41 @@ function findPageForFragment(fragment: androidx.fragment.app.Fragment, frame: Fr entry.fragment = fragment; _updateTransitions(entry); } else { - throw new Error(`Could not find a page for ${fragmentTag}.`); + // Android restored a fragment no live entry owns anymore - the frame navigated on (or was + // reset) while the activity was being recreated. Throwing is fatal across the JNI boundary + // and this fragment can never produce a view, so discard it instead. + Trace.write(`Could not find a page for ${fragmentTag}. Discarding orphaned fragment.`, Trace.categories.NativeLifecycle, Trace.messageType.error); + removeFragmentIfAdded(fragment); + } +} + +/** + * Drops a fragment the JS side no longer owns from its FragmentManager, so the manager stops + * driving it through the lifecycle. The removal is always deferred - callers may be inside a + * FragmentManager transaction, where commitNow throws "already executing transactions". + */ +export function removeFragmentIfAdded(fragment: androidx.fragment.app.Fragment): void { + if (!fragment.isAdded()) { + return; + } + + const manager = fragment.getParentFragmentManager(); + if (manager.isDestroyed()) { + return; + } + + manager.beginTransaction().remove(fragment).commitAllowingStateLoss(); +} + +/** + * Breaks the fragment -> BackstackEntry link so a fragment the FragmentManager is still holding + * cannot bring a discarded entry (and its torn down page) back into the frame. + */ +export function detachFragmentCallbacks(fragment: androidx.fragment.app.Fragment): void { + const callbacks: FragmentCallbacksImplementation = fragment[CALLBACKS]; + if (callbacks) { + callbacks.entry = null; + callbacks.frame = null; } } @@ -214,7 +252,7 @@ export class FragmentCallbacksImplementation implements AndroidFragmentCallbacks const hasRemovingParent = fragment.getRemovingParentFragment(); if (hasRemovingParent) { - const nativeFrameView = this.frame.nativeViewProtected; + const nativeFrameView = this.frame?.nativeViewProtected; if (nativeFrameView) { const bitmapDrawable = new android.graphics.drawable.BitmapDrawable(getNativeApp().getApplicationContext().getResources(), this.backgroundBitmap); this.frame._originalBackground = this.frame.backgroundColor || new Color('White'); @@ -237,7 +275,10 @@ export class FragmentCallbacksImplementation implements AndroidFragmentCallbacks const entry = this.entry; if (!entry) { - Trace.error(`${fragment}.onDestroy: entry is null or undefined`); + // A fragment that was never bound to an entry, or whose entry has already been + // discarded, is still destroyed by the FragmentManager. Trace.error routes to the error + // handler, which rethrows and turns this teardown into a fatal exception. + Trace.write(`${fragment}.onDestroy: entry is null or undefined`, Trace.categories.NativeLifecycle, Trace.messageType.error); return null; } @@ -270,7 +311,7 @@ export class FragmentCallbacksImplementation implements AndroidFragmentCallbacks const hasRemovingParent = fragment.getRemovingParentFragment(); if (hasRemovingParent) { - this.backgroundBitmap = this.loadBitmapFromView(this.frame.nativeViewProtected); + this.backgroundBitmap = this.loadBitmapFromView(this.frame?.nativeViewProtected); } } finally { superFunc.call(fragment); @@ -279,7 +320,16 @@ export class FragmentCallbacksImplementation implements AndroidFragmentCallbacks @profile public onResume(fragment: org.nativescript.widgets.FragmentBase, superFunc: Function): void { - const frame = this.entry.resolvedPage.frame; + const frame = this.entry?.resolvedPage?.frame; + if (!frame) { + // Stale fragment the FragmentManager is still driving after its entry (or page) was + // discarded: there is no navigation left to complete, and dereferencing the missing page + // would throw across the JNI boundary. + superFunc.call(fragment); + + return; + } + // on some cases during the first navigation on nested frames the animation doesn't trigger // we depend on the animation (even None animation) to set the entry as the current entry // animation should start between start and resume, so if we have an executing navigation here it probably means the animation was skipped @@ -288,7 +338,7 @@ export class FragmentCallbacksImplementation implements AndroidFragmentCallbacks const weakRef = new WeakRef(this); setTimeout(() => { const owner = weakRef.get(); - if (!owner) { + if (!owner || !owner.entry) { return; } if (!owner.entry.isAnimationRunning) { diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index 11a61e64db..967438b1b8 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -16,7 +16,7 @@ import { getAppMainEntry } from '../../application/helpers-common'; import { AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData } from '../../application/application-interfaces'; import { Application } from '../../application/application'; import { isEmbedded, setEmbeddedView } from '../embedding'; -import { CALLBACKS, FRAMEID, framesCache, setFragmentCallbacks } from './frame-helper-for-android'; +import { CALLBACKS, detachFragmentCallbacks, FRAMEID, framesCache, removeFragmentIfAdded, setFragmentCallbacks } from './frame-helper-for-android'; import { SDK_VERSION } from '../../utils'; export * from './frame-common'; @@ -459,8 +459,18 @@ export class Frame extends FrameBase { public _removeEntry(removed: BackstackEntry): void { super._removeEntry(removed); - if (removed.fragment) { + const fragment = removed.fragment; + if (fragment) { _clearEntry(removed); + + // The entry is gone now - its page was torn down and resolvedPage cleared - but the + // fragment can still be sitting in the FragmentManager: navigation only evicts fragments + // sharing the frame's current container, so anything left over from an activity + // recreation or a frame reset survives. Cut it loose here, or the FragmentManager keeps + // driving it through the lifecycle against a dead entry. + detachFragmentCallbacks(fragment); + removeFragmentIfAdded(fragment); + removed.fragment = null; }