Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 1, 2025

GitHub Issue (If applicable): closes #2905

PR Type

What kind of change does this PR introduce?

  • Bugfix

What is the current behavior?

When navigating from Page A to Page B with NavigateViewModelForResultAsync, if the user triggers back navigation (via NavigationBar MainCommand or hardware back button) before Page B's ViewModel finishes loading, the awaited navigation call hangs forever. The ResponseNavigator's TaskCompletionSource never completes because back navigation through Frame_Navigated event bypasses the ResponseNavigator.NavigateAsync flow.

What is the new behavior?

Back navigation now correctly completes pending ResponseNavigator instances regardless of how the navigation is triggered. The FrameNavigator.NavigatedBackAsync method checks for any pending ResponseNavigator in the service provider and completes it before restoring the previous navigator.

Changes:

  • IResponseNavigator: Added CompleteWithResult(object?) method
  • ResponseNavigator<TResult>: Implemented CompleteWithResult as an explicit interface implementation to fix TypeLoadException when the internal interface is used across assembly boundaries. Added private CompleteWithResultCore method with early-exit check for thread safety.
  • FrameNavigator.NavigatedBackAsync: Detects and completes pending ResponseNavigator on back navigation

PR Checklist

Please check if your PR fulfills the following requirements:

  • Tested code with current supported SDKs
  • Docs have been added/updated which fit documentation template. (for bug fixes / features)
  • Unit Tests and/or UI Tests for the changes have been added (for bug fixes / features) (if applicable)
  • Wasm UI Tests are not showing unexpected any differences. Validate PR Screenshots Compare Test Run results.
  • Contains NO breaking changes
  • Updated the Release Notes
  • Associated with an issue (GitHub or internal)

Other information

Internal Issue (If applicable):


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI changed the title [WIP] Fix navigation race condition with NavigateForResult Fix navigation race condition with NavigateForResult Dec 1, 2025
Copilot AI requested a review from kazo0 December 1, 2025 21:35
Copilot finished work on behalf of kazo0 December 1, 2025 21:35
@kazo0 kazo0 marked this pull request as ready for review December 2, 2025 00:40
Copilot AI review requested due to automatic review settings December 2, 2025 00:40
Copilot finished reviewing on behalf of kazo0 December 2, 2025 00:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a race condition where NavigateViewModelForResultAsync calls would hang indefinitely if the user triggered back navigation (via NavigationBar or hardware back button) before the target page's ViewModel finished loading.

Key Changes:

  • Added CompleteWithResult method to IResponseNavigator interface to allow external completion of pending navigation results
  • Implemented the method in ResponseNavigator<TResult> with early-exit checks to avoid processing already-completed results
  • Modified FrameNavigator.NavigatedBackAsync to detect and complete any pending ResponseNavigator before restoring the previous navigator

Reviewed changes

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

File Description
src/Uno.Extensions.Navigation/IResponseNavigator.cs Added CompleteWithResult method to interface for completing pending navigation results from external callers
src/Uno.Extensions.Navigation.UI/ResponseNavigator.cs Refactored result completion logic into new CompleteWithResult method with early-exit check, extracted from NavigateAsync
src/Uno.Extensions.Navigation.UI/Navigators/FrameNavigator.cs Added logic to detect and complete pending ResponseNavigator during back navigation to prevent hung awaits

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +54 to +59
// Early return if already completed to avoid unnecessary processing
if (ResultCompletion.Task.Status == TaskStatus.Canceled ||
ResultCompletion.Task.Status == TaskStatus.RanToCompletion)
{
return;
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

The check on lines 55-56 for task completion status is not thread-safe. Between checking the status and calling ApplyResult, another thread could complete the task. This creates a TOCTOU (Time-Of-Check-Time-Of-Use) race condition.

Consider using a lock or interlocked operations to ensure atomicity, or rely on the fact that TrySetResult in line 94 is already thread-safe and will fail gracefully if the task is already completed. The duplicate check in ApplyResult (lines 83-84) provides some protection but doesn't eliminate the race window completely.

Alternatively, use an atomic flag to track completion state:

private int _isCompleted = 0;

public async Task CompleteWithResult(object? responseData)
{
    if (Interlocked.CompareExchange(ref _isCompleted, 1, 0) == 1)
    {
        return;
    }
    // ... rest of the logic
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The early return check is intentionally a performance optimization, not a thread-safety mechanism. The actual thread-safety is provided by TrySetResult in ApplyResult which is guaranteed to be atomic. The duplicate check in ApplyResult (lines 81-84) is the authoritative one. The early check in CompleteWithResultCore just avoids unnecessary type conversion work when the result is already set.

Comment on lines +262 to +271
// Complete any pending ResponseNavigator before restoring the previous navigator.
// This handles the case when back navigation occurs outside of the ResponseNavigator's
// NavigateAsync method (e.g., hardware back button, NavigationBar back button pressed
// before ViewModel is fully loaded).
var currentNavigator = Region.Services?.GetInstance<INavigator>();
if (currentNavigator is IResponseNavigator responseNavigator)
{
var responseData = request.Route.ResponseData();
await responseNavigator.CompleteWithResult(responseData);
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

The navigator restoration logic (line 90) is executed inside ApplyResult, which is called from CompleteWithResult. This means the navigator is restored before the TaskCompletionSource is set on line 94.

However, in FrameNavigator.NavigatedBackAsync (line 274-277), there's separate logic to restore the navigator from CurrentView. This could lead to a race condition where:

  1. CompleteWithResult restores the navigator (line 90)
  2. Then FrameNavigator restores it again from CurrentView (line 277)

This could result in the wrong navigator being set. Consider coordinating this logic or ensuring that the navigator restoration happens only once in the correct place.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is actually intentional - the ResponseNavigator.ApplyResult restores the original navigator (the one that was wrapped), while FrameNavigator then restores the navigator stored on the previous page. These are typically the same navigator instance. The order is: (1) ResponseNavigator completes and restores its wrapped navigator, (2) FrameNavigator restores the navigator from the page we're navigating back to. This ensures the service provider has the correct navigator for the destination page.

Comment on lines +262 to +271
// Complete any pending ResponseNavigator before restoring the previous navigator.
// This handles the case when back navigation occurs outside of the ResponseNavigator's
// NavigateAsync method (e.g., hardware back button, NavigationBar back button pressed
// before ViewModel is fully loaded).
var currentNavigator = Region.Services?.GetInstance<INavigator>();
if (currentNavigator is IResponseNavigator responseNavigator)
{
var responseData = request.Route.ResponseData();
await responseNavigator.CompleteWithResult(responseData);
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

This fix addresses a race condition with NavigateForResult, but there are no tests covering this scenario. The testing infrastructure exists in the repository (see testing/TestHarness/TestHarness.UITest), but no tests verify the behavior when:

  1. A NavigateForResult call is awaited
  2. Back navigation occurs before the target page's ViewModel finishes loading
  3. The awaited task should complete (not hang)

Consider adding a UI test that:

  • Navigates from Page A to Page B using NavigateViewModelForResultAsync
  • Triggers back navigation via hardware/UI back button before Page B's ViewModel is fully loaded
  • Verifies that the awaited navigation call completes and doesn't hang

This would prevent regression of issue #2905.

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 76
if (responseData is TResult data)
{
result = Option.Some(data);
}
else
{
result = Option.None<TResult>();
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
if (responseData is TResult data)
{
result = Option.Some(data);
}
else
{
result = Option.None<TResult>();
}
result = responseData is TResult data ? Option.Some(data) : Option.None<TResult>();

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Applied in bd282c0 - simplified the if/else to a ternary operator.

@kazo0
Copy link
Contributor

kazo0 commented Dec 2, 2025

@copilot While testing this in a local app I am getting this error:

System.TypeLoadException: 'Method 'CompleteWithResult' in type 'Uno.Extensions.Navigation.ResponseNavigator`1' from assembly 'Uno.Extensions.Navigation.UI, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null' does not have an implementation.'

Copy link
Contributor Author

Copilot AI commented Dec 2, 2025

@copilot While testing this in a local app I am getting this error:

System.TypeLoadException: 'Method 'CompleteWithResult' in type 'Uno.Extensions.Navigation.ResponseNavigator`1' from assembly 'Uno.E...

Fixed in bd282c0 by using explicit interface implementation (async Task IResponseNavigator.CompleteWithResult(...)) instead of a public method. This ensures the method is properly visible through the internal interface while avoiding the TypeLoadException that occurs when a public class tries to implicitly implement an internal interface method across assembly boundaries.

Copilot finished work on behalf of kazo0 December 2, 2025 01:15
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.

Navigation race condition with NavigateForResult

3 participants