-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Spruce up async handling in OnNavigateAsync callback in Blazor router #23835
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
Changes from all commits
eff9da5
7479d31
27f8e31
6e12150
24bb293
c2f1b37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,6 +31,8 @@ static readonly ReadOnlyDictionary<string, object> _emptyParametersDictionary | |
|
||
private CancellationTokenSource _onNavigateCts; | ||
|
||
private Task _previousOnNavigateTask = Task.CompletedTask; | ||
|
||
private readonly HashSet<Assembly> _assemblies = new HashSet<Assembly>(); | ||
|
||
private bool _onNavigateCalled = false; | ||
|
@@ -112,7 +114,8 @@ public async Task SetParametersAsync(ParameterView parameters) | |
if (!_onNavigateCalled) | ||
{ | ||
_onNavigateCalled = true; | ||
await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute)); | ||
await RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false); | ||
return; | ||
} | ||
|
||
Refresh(isNavigationIntercepted: false); | ||
|
@@ -122,7 +125,6 @@ public async Task SetParametersAsync(ParameterView parameters) | |
public void Dispose() | ||
{ | ||
NavigationManager.LocationChanged -= OnLocationChanged; | ||
_onNavigateCts?.Dispose(); | ||
} | ||
|
||
private static string StringUntilAny(string str, char[] chars) | ||
|
@@ -147,7 +149,7 @@ private void RefreshRouteTable() | |
|
||
} | ||
|
||
private void Refresh(bool isNavigationIntercepted) | ||
internal virtual void Refresh(bool isNavigationIntercepted) | ||
{ | ||
RefreshRouteTable(); | ||
|
||
|
@@ -190,28 +192,31 @@ private void Refresh(bool isNavigationIntercepted) | |
} | ||
} | ||
|
||
private async Task RunOnNavigateAsync(string path) | ||
private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNavigate) | ||
{ | ||
// If this router instance does not provide an OnNavigateAsync parameter | ||
// then we render the component associated with the route as per usual. | ||
if (!OnNavigateAsync.HasDelegate) | ||
{ | ||
return; | ||
return true; | ||
} | ||
|
||
// If we've already invoked a task and stored its CTS, then | ||
// cancel the existing task. | ||
_onNavigateCts?.Dispose(); | ||
// cancel that existing CTS. | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_onNavigateCts?.Cancel(); | ||
// Then make sure that the task has been completed cancelled or | ||
// completed before continuing with the execution of this current task. | ||
await previousOnNavigate; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sure this So it makes me wonder why we couldn't just ignore Also I spent a while being confused about error handling here, because I thought that if So, it would be good to clarify in the comment why we want to await this, and why we know it will never fail or be cancelled :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommended this when reviewing the the CTS usage. I didn't want to have to think about the possible consequences of the meat of RunOnNavigateWithRefreshAsync running concurrently of itself, so in my mind this simplified things. I cannot point to any specific bug this prevents though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @halter73 Thanks for posting this response. I also updated the commenting around here in a follow-up PR to make ti clearer. |
||
|
||
// Create a new cancellation token source for this instance | ||
_onNavigateCts = new CancellationTokenSource(); | ||
var navigateContext = new NavigationContext(path, _onNavigateCts.Token); | ||
|
||
// Create a cancellation task based on the cancellation token | ||
// associated with the current running task. | ||
var cancellationTaskSource = new TaskCompletionSource(); | ||
var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
navigateContext.CancellationToken.Register(state => | ||
((TaskCompletionSource)state).SetResult(), cancellationTaskSource); | ||
((TaskCompletionSource)state).SetResult(), cancellationTcs); | ||
|
||
var task = OnNavigateAsync.InvokeAsync(navigateContext); | ||
|
||
|
@@ -221,13 +226,34 @@ private async Task RunOnNavigateAsync(string path) | |
_renderHandle.Render(Navigating); | ||
} | ||
|
||
await Task.WhenAny(task, cancellationTaskSource.Task); | ||
var completedTask = await Task.WhenAny(task, cancellationTcs.Task); | ||
return task == completedTask; | ||
Comment on lines
+229
to
+230
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm unclear about this logic.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is a correct. If the user cancels the task themselves, we'll still render the route's component. The only time we don't render the route's component is if the
It should be pretty easy to check this. We would have to do something like
We can do
Maybe the more full-proof check is an adaptation of the one above
The whole "do something when a task throws an exception) is addressed in my other PR. You make a good point about the logic that throws the exception back to the renderer being the "OnNavigateAsyncWithRefresh" method instead of the Edit: Actually, we can keep the |
||
} | ||
|
||
private async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted) | ||
internal async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted) | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
await RunOnNavigateAsync(path); | ||
Refresh(isNavigationIntercepted); | ||
// We cache the Task representing the previously invoked RunOnNavigateWithRefreshAsync | ||
// that is stored | ||
var previousTask = _previousOnNavigateTask; | ||
// Then we create a new one that represents our current invocation and store it | ||
// globally for the next invocation. Note to the developer, if the WASM runtime | ||
// support multi-threading then we'll need to implement the appropriate locks | ||
// here to ensure that the cached previous task is overwritten incorrectly. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I might be wrong here, but if we were to support multi-threading we would still be single threaded on the UI, since changing that would be a massive breaking change of epic proportions. We would likely switch to use a synchronization context in the same way we do on the server. |
||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); | ||
_previousOnNavigateTask = tcs.Task; | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
try | ||
{ | ||
// And pass an indicator for the previous task to the currently running one. | ||
var shouldRefresh = await RunOnNavigateAsync(path, previousTask); | ||
if (shouldRefresh) | ||
{ | ||
Refresh(isNavigationIntercepted); | ||
} | ||
} | ||
finally | ||
{ | ||
tcs.SetResult(); | ||
} | ||
} | ||
|
||
private void OnLocationChanged(object sender, LocationChangedEventArgs args) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
// Copyright (c) .NET Foundation. All rights reserved. | ||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Microsoft.AspNetCore.Components; | ||
using Microsoft.AspNetCore.Components.Routing; | ||
using Microsoft.AspNetCore.Components.Test.Helpers; | ||
using Microsoft.Extensions.DependencyModel; | ||
using Moq; | ||
using Xunit; | ||
|
||
namespace Microsoft.AspNetCore.Components.Test.Routing | ||
{ | ||
public class RouterTest | ||
{ | ||
[Fact] | ||
public async Task CanRunOnNavigateAsync() | ||
{ | ||
// Arrange | ||
var router = CreateMockRouter(); | ||
var called = false; | ||
async Task OnNavigateAsync(NavigationContext args) | ||
{ | ||
await Task.CompletedTask; | ||
called = true; | ||
} | ||
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync); | ||
|
||
// Act | ||
await router.Object.RunOnNavigateWithRefreshAsync("http://example.com/jan", false); | ||
|
||
// Assert | ||
Assert.True(called); | ||
} | ||
|
||
[Fact] | ||
public async Task CanCancelPreviousOnNavigateAsync() | ||
{ | ||
// Arrange | ||
var router = CreateMockRouter(); | ||
var cancelled = ""; | ||
async Task OnNavigateAsync(NavigationContext args) | ||
{ | ||
await Task.CompletedTask; | ||
args.CancellationToken.Register(() => cancelled = args.Path); | ||
}; | ||
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync); | ||
|
||
// Act | ||
await router.Object.RunOnNavigateWithRefreshAsync("jan", false); | ||
await router.Object.RunOnNavigateWithRefreshAsync("feb", false); | ||
|
||
// Assert | ||
var expected = "jan"; | ||
Assert.Equal(cancelled, expected); | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
[Fact] | ||
public async Task RefreshesOnceOnCancelledOnNavigateAsync() | ||
{ | ||
// Arrange | ||
var router = CreateMockRouter(); | ||
async Task OnNavigateAsync(NavigationContext args) | ||
{ | ||
if (args.Path.EndsWith("jan")) | ||
{ | ||
await Task.Delay(Timeout.Infinite); | ||
} | ||
}; | ||
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync); | ||
|
||
// Act | ||
var janTask = router.Object.RunOnNavigateWithRefreshAsync("jan", false); | ||
var febTask = router.Object.RunOnNavigateWithRefreshAsync("feb", false); | ||
|
||
var janTaskException = await Record.ExceptionAsync(() => janTask); | ||
var febTaskException = await Record.ExceptionAsync(() => febTask); | ||
|
||
// Assert neither exceution threw an exception | ||
Assert.Null(janTaskException); | ||
Assert.Null(febTaskException); | ||
// Assert refresh should've only been called once for the second route | ||
router.Verify(x => x.Refresh(false), Times.Once()); | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like you have well covered the cases where cancellation occurs due to a second navigation. I'd love to see some additional test cases covering scenarios where the OnNavigateAsync task itself is what completes first, in success, failure, and cancelled status. For example:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These are good ideas. I'll add these scenarios as unit tests in the other PR. |
||
private Mock<Router> CreateMockRouter() | ||
{ | ||
var router = new Mock<Router>() { CallBase = true }; | ||
router.Setup(x => x.Refresh(It.IsAny<bool>())).Verifiable(); | ||
return router; | ||
} | ||
|
||
[Route("jan")] | ||
private class JanComponent : ComponentBase { } | ||
|
||
[Route("feb")] | ||
private class FebComponent : ComponentBase { } | ||
captainsafia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.