Skip to content

fix(core): Better handling for nested frames #10713

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

CatchABus
Copy link
Contributor

@CatchABus CatchABus commented Mar 1, 2025

PR Checklist

What is the current behavior?

Right now, core has inconsistencies with nested Frames when they get destroyed and reused.
This results in errors like the No view found for id exception users have been getting lately.
The problem is more apparent in flavors like Vue where the entire view tree is being reused after application exit and resume.

What is the new behavior?

This PR ensures Frame fragments are created and attached in the correct order to prevent errors.
Previously, we had a workaround included in #9725 to take care of similar problems but unfortunately the error still persists.

The new approach was developed with respecting existing functionality and event lifecycle in mind.
More specifically, it ensures Frame event lifecycle order is maintained (e.g. loaded, navigatedTo) and allows the previous workaround to be used as a precaution at the same time.

Few things to note:

  • Added ViewBase isLoadingSubviews getter, mostly for internal usage
  • Moved transition state cache function to fragment transition module
  • Replaced any with the corresponding types
  • Replaced deprecated string substr calls with substring
  • Call _frameCreateTimeout only if needed and unset _frameCreateTimeout upon execution
  • Method _restoreTransitionState applied the restored state to the wrong entry at times, triggering strange errors like (onDestroy: entry has no resolvedPage)
  • Unset cached transition state during native view disposal

Fixes/Implements/Closes #8126.

Summary by CodeRabbit

  • New Features

    • Introduced improved event handling for frame loading, allowing better coordination of nested frame loading states.
    • Added new state tracking for when subviews are loading within views.
    • Enabled saving and restoring of transition states during navigation for smoother transitions.
  • Bug Fixes

    • Improved cleanup of transition state and event listeners to prevent memory leaks and stale state.
  • Tests

    • Added a test to verify the correct load order of nested frames.
  • Refactor

    • Enhanced type safety and clarity in navigation and animation methods.
    • Centralized and streamlined transition management logic for better maintainability.

Copy link

nx-cloud bot commented May 11, 2025

View your CI Pipeline Execution ↗ for commit b2e3fbd.

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 4m 50s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded 3s View ↗

☁️ Nx Cloud last updated this comment at 2025-05-31 17:13:21 UTC

@CatchABus CatchABus force-pushed the fix/android-no-view-for-id branch from a9d0902 to 67ce09a Compare May 11, 2025 19:22
@CatchABus CatchABus marked this pull request as ready for review May 17, 2025 19:58
@CatchABus
Copy link
Contributor Author

CatchABus commented May 17, 2025

@NathanWalker This should be good to go. Let me say that theoritically, this PR doesn't completely solve the problem because we have some setCurrent calls wrapped inside setTimeout and that can also mess the order of execution for frames.
Regardless, it should fix the suspend/resume problem mentioned in the issue.

UPDATED: This has actually got more room for improvements.

@CatchABus CatchABus marked this pull request as draft May 17, 2025 23:57
@CatchABus
Copy link
Contributor Author

It seems the problem goes a bit deeper so I'll keep this one as draft for just a little.

@NathanWalker
Copy link
Contributor

@coderabbitai please review

Copy link

coderabbitai bot commented May 29, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@NathanWalker I'll review this PR for you. Let me gather the details about the changes first.


🏁 Script executed:

#!/bin/bash
# Get PR details and information
gh pr view 10713 --json title,body,state,isDraft,files,additions,deletions,headRefName,baseRefName

# Get the diff for this PR to understand the changes
gh pr diff 10713

# Look for any related issues mentioned
gh pr view 10713 --json body | jq -r '.body' | grep -i -E '(fix|close|resolve|issue|#[0-9]+)'

Length of output: 23664


Review of PR #10713: Better handling for nested frames

@NathanWalker This PR addresses a critical issue with nested frame management in NativeScript core. Here's my comprehensive review:

🎯 Core Problem & Solution

The PR tackles the "No view found for id" exception that occurs when nested frames are destroyed and reused, particularly evident in Vue applications. The solution introduces proper sequencing of frame loading through an event-driven approach.

Architectural Improvements

  1. Proper Loading Sequencing: The addition of isLoadingSubviews tracking and event coordination ensures parent frames complete their setup before child frames begin processing.

  2. Code Organization: Moving transition state management (_getTransitionState, _restoreTransitionState) to fragment.transitions.android.ts improves separation of concerns.

  3. Type Safety: Excellent improvements replacing any types with proper BackstackEntry types.

⚠️ Areas of Concern

  1. Race Conditions: The event-driven approach could introduce timing issues:
// In frame-common.ts
if (parentFrame && parentFrame.isLoadingSubviews) {
    parentFrame.once('frameEntryLoaded', () => {
        this.onFrameLoaded();
    });
}

Concern: What if parentFrame.isLoadingSubviews becomes false between the check and event registration?

  1. setTimeout Dependencies: As you mentioned, there are still setTimeout calls that could affect execution order:
// In index.android.ts
this._frameCreateTimeout = setTimeout(() => {
    // Fragment recreation logic
}, 0);
  1. Memory Management: Event listeners need proper cleanup to prevent memory leaks.

🔧 Specific Code Issues

  1. Null Safety Enhancement Needed:
// Consider adding null check
const parentFrame = this.page?.frame;
if (parentFrame?.isLoadingSubviews) {
    // ...
}
  1. Event Cleanup: The once listener should be cleaned up if the frame is destroyed before the event fires.

  2. Error Handling: Missing error handling for fragment creation failures.

🧪 Testing Observations

The new test test_frame_entry_loaded_order() is good but consider adding:

  • Tests for error scenarios (fragment creation failures)
  • Tests for rapid suspend/resume cycles
  • Tests for deeply nested frame hierarchies
  • Tests for concurrent navigation in nested frames

📋 Recommendations

  1. Add Timeout Safety:
let timeoutId: number;
const cleanup = () => {
    if (timeoutId) clearTimeout(timeoutId);
    // Remove any pending listeners
};

if (parentFrame?.isLoadingSubviews) {
    timeoutId = setTimeout(() => {
        // Fallback if event never fires
        this.onFrameLoaded();
    }, 5000);
    
    parentFrame.once('frameEntryLoaded', () => {
        cleanup();
        this.onFrameLoaded();
    });
}
  1. Consider Using Promises for better async coordination instead of events.

  2. Add Debug Logging to help diagnose timing issues in production.

🎯 Overall Assessment

This is a solid architectural improvement that addresses a real pain point. The event-driven approach is conceptually correct, but needs additional safeguards for robustness. The code quality improvements (type safety, organization) are excellent.

Recommendation: Address the race condition concerns and add timeout safeguards before moving out of draft status. Consider adding more comprehensive error handling for edge cases.

The PR is on the right track but needs these reliability improvements to handle the complex timing scenarios that can occur with nested frames in production applications.

This comment was marked as resolved.

@CatchABus
Copy link
Contributor Author

@coderabbitai review

Copy link

coderabbitai bot commented May 31, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (4)
apps/automated/src/ui/frame/frame-tests-common.ts (1)

272-320: Well-structured test for frame loading coordination.

The test effectively validates the nested frame loading order, which is crucial for the PR's objectives. The test methodology is sound:

  1. Creates root and nested frames in proper hierarchy
  2. Overrides onFrameLoaded to track execution order
  3. Validates root frame loads before nested frame
  4. Proper cleanup of overrides and frame stack

However, consider a few improvements:

Consider adding error handling and more detailed assertions:

+	// Ensure we have exactly 2 frames loaded
+	TKUnit.assertEqual(loadedFrames.length, 2, "Expected exactly 2 frames to be loaded");
+	
 	TKUnit.assertEqual(rootFrame, loadedFrames[0]);
 	TKUnit.assertEqual(nestedFrame, loadedFrames[1]);
+	
+	// Verify frames are in expected loaded state
+	TKUnit.assertTrue(rootFrame.isLoaded, "Root frame should be loaded");
+	TKUnit.assertTrue(nestedFrame.isLoaded, "Nested frame should be loaded");

Also consider testing error scenarios where frames might fail to load properly.

packages/core/ui/frame/frame-common.ts (1)

356-361: Missing documentation for new internal event

_notifyFrameEntryLoaded() emits _frameEntryLoaded, but neither the JSDoc nor public typings mention the event.
Please add a short comment so maintainers know this is internal and avoid subscribing from user code.

packages/core/ui/frame/fragment.transitions.android.ts (2)

436-444: Cloned transition listeners keep strong reference to original entry

cloneExpandedTransitionListener reuses expandedTransitionListener.entry, so every clone still references the old entry object.
That is intended for restore, but if the snapshot outlives the entry’s fragment (e.g. orientation change) the listener keeps the fragment from GC.

If unavoidable, at least wrap entry in a WeakRef inside the listener, or ensure _restoreTransitionState executes quickly and the snapshot is nulled afterwards (see previous comment).


155-156: String slicing duplicated & fragile

'flip'.length / 'slide'.length string-math is repeated three times.
A simple helper improves readability and avoids mismatched literals:

function stripPrefix(value: string, prefix: string) {
  return value.startsWith(prefix) ? value.substring(prefix.length) : value;
}
const direction = stripPrefix(name, 'flip') || 'right';

Also applies to: 628-629, 653-654

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93e6eb9 and 4b023a6.

📒 Files selected for processing (8)
  • apps/automated/src/ui/frame/frame-tests-common.ts (2 hunks)
  • packages/core/ui/core/view-base/index.ts (4 hunks)
  • packages/core/ui/frame/callbacks/activity-callbacks.ts (0 hunks)
  • packages/core/ui/frame/fragment.transitions.android.ts (7 hunks)
  • packages/core/ui/frame/fragment.transitions.d.ts (2 hunks)
  • packages/core/ui/frame/frame-common.ts (4 hunks)
  • packages/core/ui/frame/index.android.ts (7 hunks)
  • packages/core/ui/frame/index.ios.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • packages/core/ui/frame/callbacks/activity-callbacks.ts
🧰 Additional context used
🧬 Code Graph Analysis (3)
packages/core/ui/frame/fragment.transitions.d.ts (1)
packages/core/ui/frame/fragment.transitions.android.ts (2)
  • _getTransitionState (487-504)
  • _restoreTransitionState (506-526)
packages/core/ui/frame/index.ios.ts (1)
packages/core/ui/frame/index.d.ts (2)
  • BackstackEntry (454-484)
  • NavigationTransition (415-449)
packages/core/ui/frame/index.android.ts (3)
packages/core/ui/frame/fragment.transitions.android.ts (3)
  • _getTransitionState (487-504)
  • _updateTransitions (283-309)
  • _restoreTransitionState (506-526)
packages/core/ui/frame/frame-common.ts (2)
  • currentEntry (559-565)
  • BackstackEntry (18-18)
packages/core/ui/frame/index.d.ts (1)
  • BackstackEntry (454-484)
🪛 Biome (1.9.4)
packages/core/ui/frame/frame-common.ts

[error] 84-84: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

🔇 Additional comments (11)
packages/core/ui/core/view-base/index.ts (4)

120-127: LGTM! Excellent type safety improvement.

The generic enhancement to getAncestor provides better type safety by allowing the return type to be inferred from the criterion parameter. The type guard approach ensures proper type narrowing.


349-349: Good addition for subview loading coordination.

The private _isLoadingSubviews field supports the new event-driven frame loading mechanism described in the PR objectives.


642-644: Proper encapsulation of loading state.

The public getter provides read-only access to the internal loading state, which is appropriate for the frame coordination mechanism.


706-714: Verify thread safety and exception handling.

The loading state tracking implementation looks correct, but consider potential edge cases:

  1. Exception handling: If an exception occurs while loading child views, the flag might remain true indefinitely
  2. Re-entrancy: Consider what happens if onLoaded is called recursively during child loading

Consider wrapping the child loading logic in a try-finally block to ensure the flag is always reset:

 this._isLoadingSubviews = true;
-
-this.eachChild((child) => {
-	this.loadView(child);
-
-	return true;
-});
-
-this._isLoadingSubviews = false;
+try {
+	this.eachChild((child) => {
+		this.loadView(child);
+		return true;
+	});
+} finally {
+	this._isLoadingSubviews = false;
+}
packages/core/ui/frame/fragment.transitions.d.ts (2)

1-1: LGTM! Import added for new transition state functionality.

The TransitionState import is correctly added to support the new transition state management functions.


23-30: Good modularization of transition state management.

The new function declarations provide a clean API for saving and restoring transition states, which should help with the nested frame lifecycle issues mentioned in the PR objectives.

The function signatures are appropriate:

  • _getTransitionState returns a snapshot of the current state
  • _restoreTransitionState accepts a snapshot to restore
apps/automated/src/ui/frame/frame-tests-common.ts (1)

8-8: LGTM! Import added for test requirements.

The StackLayout import is correctly added to support the new nested frame test.

packages/core/ui/frame/index.ios.ts (2)

91-91: LGTM! Type safety improvement for navigation core.

Changing the parameter type from any to BackstackEntry provides better type safety and aligns with the Android implementation improvements in this PR.


520-520: LGTM! Refined callback type annotation.

The change from generic Function to specific () => void improves type safety by explicitly indicating the callback takes no parameters and returns void. This is more precise than the generic Function type.

packages/core/ui/frame/index.android.ts (1)

541-565: Dispose transition snapshot eagerly

disposeNativeView sets _cachedTransitionState = null but does not remove any listeners that might have been cloned inside the snapshot.
If disposeNativeView fires before the simulated navigation completes, the cloned transitions may still point to disposed fragments.

Consider invoking _clearEntry(_cachedTransitionState?.entry) before nullifying the snapshot.

packages/core/ui/frame/fragment.transitions.android.ts (1)

283-289: Good safeguard

Early-returning when fragment is falsy prevents NPEs during fragment restoration. Nice catch.

Comment on lines 124 to +144
@profile
public onLoaded() {
// Property page refers to the page this frame is nested into
const parentFrame = this.page?.frame;

super.onLoaded();

if (parentFrame?.isLoadingSubviews) {
const frameRef = new WeakRef(this);

parentFrame.once(FRAME_ENTRY_LOADED_EVENT, () => {
const frame = frameRef.deref();
if (frame) {
frame.onFrameLoaded();
}
});
} else {
this.onFrameLoaded();
}
}

Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Potential dangling listener on parent frame

onLoaded registers a once handler on parentFrame but never stores a reference to that frame.
If the child frame is unloaded before the parent fires _frameEntryLoaded, that handler remains attached to the parent for the lifetime of the app, causing a memory leak.

Consider capturing parentFrame in a weak ref and detaching the listener from the parent inside onUnloaded (or when the child is destroyed).

// capture
const parentFrameListener = () => { … }
parentFrame.once(FRAME_ENTRY_LOADED_EVENT, parentFrameListener);

this._parentFrameRef = new WeakRef(parentFrame);
this._parentFrameListener = parentFrameListener;

and later:

public override onUnloaded() {
  …
- if (this.hasListeners(FRAME_ENTRY_LOADED_EVENT)) {
-   this.off(FRAME_ENTRY_LOADED_EVENT);
- }
+ const parent = this._parentFrameRef?.deref?.();
+ if (parent && this._parentFrameListener) {
+   parent.off(FRAME_ENTRY_LOADED_EVENT, this._parentFrameListener);
+ }
}

I’d like to confirm whether onUnloaded is implemented in this class (and that parent.off is available), so we can determine if the suggested cleanup hook is feasible:


🏁 Script executed:

#!/bin/bash
# Locate frame-common.ts and inspect for onUnloaded override
file=$(fd frame-common.ts)
echo "Inspecting: $file"
rg -n "onUnloaded" -C3 "$file"

# Check the FRAME_ENTRY_LOADED_EVENT declaration for context
rg -n "FRAME_ENTRY_LOADED_EVENT" -C3 "$file"

Length of output: 1658


Fix dangling listener on parent frame

The onLoaded method registers a once listener on parentFrame, but the existing onUnloaded only calls this.off on the child frame—leaving the parent-attached callback alive if the child is unloaded first. To prevent this leak, capture both the parent reference and the listener callback, then remove it from the parent in onUnloaded.

• File: packages/core/ui/frame/frame-common.ts
– onLoaded (lines ~131–139): store parentFrame and the listener in class fields
– onUnloaded (lines 146–153): call parent.off(FRAME_ENTRY_LOADED_EVENT, listener)

Suggested diff:

diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts
index …  
--- a/packages/core/ui/frame/frame-common.ts
+++ b/packages/core/ui/frame/frame-common.ts
@@ class FrameCommon {
+   private _parentFrameRef?: WeakRef<FrameCommon>;
+   private _frameLoadListener?: () => void;
 
     @profile
     public onLoaded() {
         super.onLoaded();
         const parentFrame = this.page?.frame;
 
         if (parentFrame?.isLoadingSubviews) {
-            const frameRef = new WeakRef(this);
-            parentFrame.once(FRAME_ENTRY_LOADED_EVENT, () => {
-                const frame = frameRef.deref();
-                if (frame) {
-                    frame.onFrameLoaded();
-                }
-            });
+            this._parentFrameRef    = new WeakRef(parentFrame);
+            const frameRef           = new WeakRef(this);
+            this._frameLoadListener  = () => {
+                const frame = frameRef.deref();
+                if (frame) {
+                    frame.onFrameLoaded();
+                }
+            };
+            parentFrame.once(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
         } else {
             this.onFrameLoaded();
         }
@@
     @profile
     public onUnloaded() {
         super.onUnloaded();
 
-        // This is a precaution in case the method is called asynchronously during the loading procedure
-        if (this.hasListeners(FRAME_ENTRY_LOADED_EVENT)) {
-            this.off(FRAME_ENTRY_LOADED_EVENT);
-        }
+        const parent = this._parentFrameRef?.deref();
+        if (parent && this._frameLoadListener) {
+            parent.off(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
+            this._frameLoadListener = undefined;
+            this._parentFrameRef    = undefined;
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@profile
public onLoaded() {
// Property page refers to the page this frame is nested into
const parentFrame = this.page?.frame;
super.onLoaded();
if (parentFrame?.isLoadingSubviews) {
const frameRef = new WeakRef(this);
parentFrame.once(FRAME_ENTRY_LOADED_EVENT, () => {
const frame = frameRef.deref();
if (frame) {
frame.onFrameLoaded();
}
});
} else {
this.onFrameLoaded();
}
}
--- a/packages/core/ui/frame/frame-common.ts
+++ b/packages/core/ui/frame/frame-common.ts
@@ class FrameCommon {
+ private _parentFrameRef?: WeakRef<FrameCommon>;
+ private _frameLoadListener?: () => void;
@profile
public onLoaded() {
- // Property page refers to the page this frame is nested into
- const parentFrame = this.page?.frame;
-
- super.onLoaded();
+ super.onLoaded();
+ // Property page refers to the page this frame is nested into
+ const parentFrame = this.page?.frame;
if (parentFrame?.isLoadingSubviews) {
- const frameRef = new WeakRef(this);
-
- parentFrame.once(FRAME_ENTRY_LOADED_EVENT, () => {
- const frame = frameRef.deref();
- if (frame) {
- frame.onFrameLoaded();
- }
- });
+ this._parentFrameRef = new WeakRef(parentFrame);
+ const frameRef = new WeakRef(this);
+ this._frameLoadListener = () => {
+ const frame = frameRef.deref();
+ if (frame) {
+ frame.onFrameLoaded();
+ }
+ };
+ parentFrame.once(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
} else {
this.onFrameLoaded();
}
@@
@profile
public onUnloaded() {
super.onUnloaded();
-
- // This is a precaution in case the method is called asynchronously during the loading procedure
- if (this.hasListeners(FRAME_ENTRY_LOADED_EVENT)) {
- this.off(FRAME_ENTRY_LOADED_EVENT);
- }
+ const parent = this._parentFrameRef?.deref();
+ if (parent && this._frameLoadListener) {
+ parent.off(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
+ this._frameLoadListener = undefined;
+ this._parentFrameRef = undefined;
+ }
}
}
🤖 Prompt for AI Agents
In packages/core/ui/frame/frame-common.ts around lines 124 to 144, the onLoaded
method adds a once listener to parentFrame without storing references, causing a
potential memory leak if the child unloads first. Fix this by saving parentFrame
and the listener callback as class fields in onLoaded, then override onUnloaded
(around lines 146 to 153) to remove the listener from parentFrame using
parent.off(FRAME_ENTRY_LOADED_EVENT, listener), ensuring proper cleanup of the
event listener.

Comment on lines +248 to +269
protected override _notifyFrameEntryLoaded(): void {
const currentEntry = this._currentEntry || this._executingContext?.entry;

// Note: This is kept as a precaution and must execute before emitting the frame entry event.
// There's a bug with nested frames where sometimes the nested fragment is not recreated at all
// so we manually check on loaded event if the fragment is not recreated and recreate it
if (currentEntry && !currentEntry.fragment) {
this._frameCreateTimeout = setTimeout(() => {
const manager = this._getFragmentManager();
const transaction = manager.beginTransaction();

currentEntry.fragment = this.createFragment(currentEntry, currentEntry.fragmentTag);
_updateTransitions(currentEntry);
transaction.replace(this.containerViewId, currentEntry.fragment, currentEntry.fragmentTag);
transaction.commitAllowingStateLoss();

this._frameCreateTimeout = null;
}, 0);
}

super._notifyFrameEntryLoaded();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Timeout cleanup is broken & may cause leaks

setTimeout returns a NodeJS.Timeout object (on NativeScript’s global typings) – not a number.
typeof this._frameCreateTimeout === 'number' will therefore be false, preventing clearTimeout from running and leaking both the timer and its closure.

Apply the fix below and also clear any pending timeout before scheduling a new one to avoid multiple queued fragment creations:

- if (currentEntry && !currentEntry.fragment) {
-   this._frameCreateTimeout = setTimeout(() => {
+ if (currentEntry && !currentEntry.fragment) {
+   if (this._frameCreateTimeout) {
+     clearTimeout(this._frameCreateTimeout as any);
+   }
+   this._frameCreateTimeout = setTimeout(() => {-     this._frameCreateTimeout = null;
+     this._frameCreateTimeout = null;
    }, 0);
 }

and in onUnloaded:

- if (typeof this._frameCreateTimeout === 'number') {
+ if (this._frameCreateTimeout) {
     clearTimeout(this._frameCreateTimeout as any);
     this._frameCreateTimeout = null;
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/core/ui/frame/index.android.ts around lines 248 to 269, the
_frameCreateTimeout is assigned the result of setTimeout, which is a
NodeJS.Timeout object, not a number. This causes typeof checks against 'number'
to fail and prevents clearTimeout from clearing the timer, leading to leaks. To
fix this, ensure _frameCreateTimeout is typed correctly to accept
NodeJS.Timeout, clear any existing timeout before setting a new one to avoid
multiple queued fragment creations, and call clearTimeout properly in onUnloaded
to clean up the timer.

Comment on lines +364 to 365
_restoreTransitionState(this._cachedTransitionState);
this._cachedTransitionState = null;
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Restored transition listeners are not re-applied to the fragment

_restoreTransitionState repopulates the …TransitionListener fields on the entry, but it never re-attaches those transitions to the native fragment (that logic lives in _updateTransitions).
After a simulated navigation the restored listeners therefore sit unused.

Either invoke _updateTransitions(snapshot.entry as ExpandedEntry) at the end of _restoreTransitionState, or call it from here right after restore:

if (this._cachedTransitionState) {
   _restoreTransitionState(this._cachedTransitionState);
+  _updateTransitions(this._cachedTransitionState.entry as any);
   this._cachedTransitionState = null;
}

Without this, subsequent back/forward navigations may miss their custom transitions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_restoreTransitionState(this._cachedTransitionState);
this._cachedTransitionState = null;
if (this._cachedTransitionState) {
_restoreTransitionState(this._cachedTransitionState);
_updateTransitions(this._cachedTransitionState.entry as any);
this._cachedTransitionState = null;
}
🤖 Prompt for AI Agents
In packages/core/ui/frame/index.android.ts around lines 364 to 365, after
calling _restoreTransitionState, the restored transition listeners are not
re-attached to the native fragment, causing them to be unused. To fix this, call
_updateTransitions with the restored entry immediately after
_restoreTransitionState to ensure the transitions are properly applied and
subsequent navigations work correctly.

Comment on lines +487 to +504
export function _getTransitionState(entry: ExpandedEntry): TransitionState {
let transitionState: TransitionState;

if (entry.enterTransitionListener && entry.exitTransitionListener) {
transitionState = {
enterTransitionListener: cloneExpandedTransitionListener(entry.enterTransitionListener),
exitTransitionListener: cloneExpandedTransitionListener(entry.exitTransitionListener),
reenterTransitionListener: cloneExpandedTransitionListener(entry.reenterTransitionListener),
returnTransitionListener: cloneExpandedTransitionListener(entry.returnTransitionListener),
transitionName: entry.transitionName,
entry,
};
} else {
transitionState = null;
}

return transitionState;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

_restoreTransitionState should refresh native transitions

The helper only reassigns listener fields on the entry; the underlying androidx.fragment.app.Fragment still carries whatever transitions were set during the simulated navigation (likely null).
Add:

_updateTransitions(entry);

at the end of _restoreTransitionState so the listeners become effective.

Failing to do so silently disables custom transitions after an app resume.

Also applies to: 506-526

🤖 Prompt for AI Agents
In packages/core/ui/frame/fragment.transitions.android.ts around lines 487 to
504, the function _restoreTransitionState currently only reassigns listener
fields on the entry object but does not update the native
androidx.fragment.app.Fragment transitions, causing custom transitions to be
disabled after app resume. To fix this, add a call to _updateTransitions(entry)
at the end of the _restoreTransitionState function to refresh the native
transitions and ensure the listeners take effect. Apply the same fix to lines
506 to 526 where a similar pattern occurs.

@CatchABus
Copy link
Contributor Author

@coderabbitai review

Copy link

coderabbitai bot commented May 31, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/core/ui/frame/frame-common.ts (2)

125-143: Critical issue: Event listener memory leak persists

The event-driven coordination logic is sound, but this implementation still has the memory leak issue identified in previous reviews. The listener registered on parentFrame is not properly tracked for cleanup.

The solution requires storing both the parent frame reference and the listener function for proper cleanup:

+private _parentFrameRef?: WeakRef<FrameBase>;
+private _frameLoadListener?: () => void;

 @profile
 public onLoaded() {
   const parentFrame = this.page?.frame;
   super.onLoaded();

   if (parentFrame?.isLoadingSubviews) {
+    this._parentFrameRef = new WeakRef(parentFrame);
     const frameRef = new WeakRef(this);
-    parentFrame.once(FRAME_ENTRY_LOADED_EVENT, () => {
+    this._frameLoadListener = () => {
       const frame = frameRef.deref();
       if (frame) {
         frame.onFrameLoaded();
       }
-    });
+    };
+    parentFrame.once(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
   } else {
     this.onFrameLoaded();
   }
 }

145-156: Critical issue: Incorrect event listener cleanup

The cleanup logic has two fundamental problems identified in previous reviews:

  1. parentFrame.off(FRAME_ENTRY_LOADED_EVENT) removes ALL listeners for this event from the parent, not just this frame's listener
  2. The condition parentFrame.hasListeners(FRAME_ENTRY_LOADED_EVENT) checks the wrong frame

Apply this fix to properly clean up the specific listener:

 @profile
 public onUnloaded() {
-  const parentFrame = this.page?.frame;
   super.onUnloaded();

-  // This is a precaution in case the method is called asynchronously during the loading procedure
-  if (parentFrame && parentFrame.hasListeners(FRAME_ENTRY_LOADED_EVENT)) {
-    parentFrame.off(FRAME_ENTRY_LOADED_EVENT);
-  }
+  const parent = this._parentFrameRef?.deref();
+  if (parent && this._frameLoadListener) {
+    parent.off(FRAME_ENTRY_LOADED_EVENT, this._frameLoadListener);
+    this._frameLoadListener = undefined;
+    this._parentFrameRef = undefined;
+  }
 }
🧰 Tools
🪛 Biome (1.9.4)

[error] 153-153: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

🧹 Nitpick comments (1)
packages/core/ui/frame/frame-common.ts (1)

84-84: Implement optional chaining for better safety

The static analysis correctly identifies opportunities to use optional chaining for cleaner and safer code.

Apply these improvements:

-if (parentFrame && parentFrame.canGoBack()) {
+if (parentFrame?.canGoBack()) {

-if (parentFrame && parentFrame.hasListeners(FRAME_ENTRY_LOADED_EVENT)) {
+if (parentFrame?.hasListeners?.(FRAME_ENTRY_LOADED_EVENT)) {

Also applies to: 153-153

🧰 Tools
🪛 Biome (1.9.4)

[error] 84-84: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b023a6 and b2e3fbd.

📒 Files selected for processing (1)
  • packages/core/ui/frame/frame-common.ts (4 hunks)
🧰 Additional context used
🪛 Biome (1.9.4)
packages/core/ui/frame/frame-common.ts

[error] 84-84: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 153-153: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: test-android
🔇 Additional comments (5)
packages/core/ui/frame/frame-common.ts (5)

20-21: LGTM: Well-defined event constant

The introduction of FRAME_ENTRY_LOADED_EVENT constant provides a clear, centralized definition for the frame coordination event.


81-81: Good improvement: Cleaner type handling

Removing the explicit type casts from getAncestor calls improves code readability and suggests better typing has been implemented upstream.

Also applies to: 87-87


158-161: LGTM: Clean separation of concerns

The extraction of frame loading logic into onFrameLoaded() with clear responsibilities (process navigation and notify) improves code organization and maintainability.


359-365: LGTM: Well-implemented event notification

The _notifyFrameEntryLoaded method provides a clean, protected interface for emitting the frame coordination event.


367-367: Good improvement: Cleaner type handling in frame ancestry

Removing the explicit type casts from getAncestor calls in the isNestedWithin method improves code readability and consistency.

Also applies to: 370-370

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.

[Android] App crash with "No view found for id"
2 participants