Make OnNavigateAsync EventCallback and cancel previous navigation (#25011)
* Make OnNavigateAsync EventCallback and cancel previous navigation * Add more tests
This commit is contained in:
parent
5193f8f428
commit
8fc1419186
|
|
@ -73,7 +73,7 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
/// <summary>
|
||||
/// Gets or sets a handler that should be called before navigating to a new page.
|
||||
/// </summary>
|
||||
[Parameter] public Func<NavigationContext, Task>? OnNavigateAsync { get; set; }
|
||||
[Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
|
||||
|
||||
private RouteTable Routes { get; set; }
|
||||
|
||||
|
|
@ -115,8 +115,7 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
if (!_onNavigateCalled)
|
||||
{
|
||||
_onNavigateCalled = true;
|
||||
await RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
|
||||
return;
|
||||
await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
|
||||
}
|
||||
|
||||
Refresh(isNavigationIntercepted: false);
|
||||
|
|
@ -206,9 +205,8 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
}
|
||||
}
|
||||
|
||||
private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNavigate)
|
||||
internal async ValueTask RunOnNavigateAsync(string path, bool isNavigationIntercepted)
|
||||
{
|
||||
|
||||
// Cancel the CTS instead of disposing it, since disposing does not
|
||||
// actually cancel and can cause unintended Object Disposed Exceptions.
|
||||
// This effectivelly cancels the previously running task and completes it.
|
||||
|
|
@ -217,59 +215,35 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
// before starting the next one. This avoid race conditions where the cancellation
|
||||
// for the previous task was set but not fully completed by the time we get to this
|
||||
// invocation.
|
||||
await previousOnNavigate;
|
||||
await _previousOnNavigateTask;
|
||||
|
||||
if (OnNavigateAsync == null)
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_previousOnNavigateTask = tcs.Task;
|
||||
|
||||
if (!OnNavigateAsync.HasDelegate)
|
||||
{
|
||||
return true;
|
||||
Refresh(isNavigationIntercepted);
|
||||
}
|
||||
|
||||
_onNavigateCts = new CancellationTokenSource();
|
||||
var navigateContext = new NavigationContext(path, _onNavigateCts.Token);
|
||||
|
||||
var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
navigateContext.CancellationToken.Register(state =>
|
||||
((TaskCompletionSource)state).SetResult(), cancellationTcs);
|
||||
|
||||
try
|
||||
{
|
||||
if (Navigating != null)
|
||||
{
|
||||
_renderHandle.Render(Navigating);
|
||||
}
|
||||
await OnNavigateAsync(navigateContext);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
{
|
||||
if (e.CancellationToken != navigateContext.CancellationToken)
|
||||
{
|
||||
var rethrownException = new InvalidOperationException("OnNavigateAsync can only be cancelled via NavigateContext.CancellationToken.", e);
|
||||
_renderHandle.Render(builder => ExceptionDispatchInfo.Throw(rethrownException));
|
||||
}
|
||||
// Task.WhenAny returns a Task<Task> so we need to await twice to unwrap the exception
|
||||
var task = await Task.WhenAny(OnNavigateAsync.InvokeAsync(navigateContext), cancellationTcs.Task);
|
||||
await task;
|
||||
tcs.SetResult();
|
||||
Refresh(isNavigationIntercepted);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_renderHandle.Render(builder => ExceptionDispatchInfo.Throw(e));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
|
||||
{
|
||||
// We cache the Task representing the previously invoked RunOnNavigateWithRefreshAsync
|
||||
// that is stored. Then we create a new one that represents our current invocation and store it
|
||||
// globally for the next invocation. This allows us to check inside `RunOnNavigateAsync` if the
|
||||
// previous OnNavigateAsync task has fully completed before starting the next one.
|
||||
var previousTask = _previousOnNavigateTask;
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_previousOnNavigateTask = tcs.Task;
|
||||
|
||||
// And pass an indicator for the previous task to the currently running one.
|
||||
var shouldRefresh = await RunOnNavigateAsync(path, previousTask);
|
||||
tcs.SetResult();
|
||||
if (shouldRefresh)
|
||||
{
|
||||
Refresh(isNavigationIntercepted);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object sender, LocationChangedEventArgs args)
|
||||
|
|
@ -277,7 +251,7 @@ namespace Microsoft.AspNetCore.Components.Routing
|
|||
_locationAbsolute = args.Location;
|
||||
if (_renderHandle.IsInitialized && Routes != null)
|
||||
{
|
||||
_ = RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted);
|
||||
_ = RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ using Microsoft.AspNetCore.Components.Test.Helpers;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Test.Routing
|
||||
{
|
||||
|
|
@ -42,49 +40,26 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
{
|
||||
// Arrange
|
||||
var called = false;
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
called = true;
|
||||
}
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
};
|
||||
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
|
||||
|
||||
// Act
|
||||
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
|
||||
|
||||
// Assert
|
||||
Assert.True(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanHandleSingleFailedOnNavigateAsync()
|
||||
{
|
||||
// Arrange
|
||||
var called = false;
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
{
|
||||
called = true;
|
||||
await Task.CompletedTask;
|
||||
throw new Exception("This is an uncaught exception.");
|
||||
}
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
|
||||
// Act
|
||||
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
|
||||
// Assert
|
||||
Assert.True(called);
|
||||
Assert.Single(_renderer.HandledExceptions);
|
||||
var unhandledException = _renderer.HandledExceptions[0];
|
||||
Assert.Equal("This is an uncaught exception.", unhandledException.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledFailedOnNavigateAsyncDoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var onNavigateInvoked = 0;
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
|
||||
{
|
||||
onNavigateInvoked += 1;
|
||||
if (args.Path.EndsWith("jan"))
|
||||
|
|
@ -92,22 +67,18 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
await Task.Delay(Timeout.Infinite, args.CancellationToken);
|
||||
throw new Exception("This is an uncaught exception.");
|
||||
}
|
||||
}
|
||||
var refreshCalled = false;
|
||||
};
|
||||
var refreshCalled = 0;
|
||||
_renderer.OnUpdateDisplay = (renderBatch) =>
|
||||
{
|
||||
if (!refreshCalled)
|
||||
{
|
||||
refreshCalled = true;
|
||||
return;
|
||||
}
|
||||
Assert.True(false, "OnUpdateDisplay called more than once.");
|
||||
refreshCalled += 1;
|
||||
return;
|
||||
};
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
|
||||
|
||||
// Act
|
||||
var janTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
var febTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
|
||||
var janTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
|
||||
var febTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
|
||||
|
||||
await janTask;
|
||||
await febTask;
|
||||
|
|
@ -115,28 +86,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
// Assert that we render the second route component and don't throw an exception
|
||||
Assert.Empty(_renderer.HandledExceptions);
|
||||
Assert.Equal(2, onNavigateInvoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanHandleSingleCancelledOnNavigateAsync()
|
||||
{
|
||||
// Arrange
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
tcs.TrySetCanceled();
|
||||
await tcs.Task;
|
||||
}
|
||||
_renderer.OnUpdateDisplay = (renderBatch) => Assert.True(false, "OnUpdateDisplay called more than once.");
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
|
||||
// Act
|
||||
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
|
||||
// Assert
|
||||
Assert.Single(_renderer.HandledExceptions);
|
||||
var unhandledException = _renderer.HandledExceptions[0];
|
||||
Assert.Equal("OnNavigateAsync can only be cancelled via NavigateContext.CancellationToken.", unhandledException.Message);
|
||||
Assert.Equal(2, refreshCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -144,7 +94,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
{
|
||||
// Arrange
|
||||
var triggerCancel = new TaskCompletionSource();
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
|
||||
{
|
||||
if (args.Path.EndsWith("jan"))
|
||||
{
|
||||
|
|
@ -153,7 +103,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
tcs.TrySetCanceled();
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
};
|
||||
var refreshCalled = false;
|
||||
_renderer.OnUpdateDisplay = (renderBatch) =>
|
||||
{
|
||||
|
|
@ -164,11 +114,11 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
}
|
||||
Assert.True(false, "OnUpdateDisplay called more than once.");
|
||||
};
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
|
||||
|
||||
// Act (start the operations then await them)
|
||||
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
|
||||
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
|
||||
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
|
||||
triggerCancel.TrySetResult();
|
||||
|
||||
await jan;
|
||||
|
|
@ -180,16 +130,16 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
{
|
||||
// Arrange
|
||||
var cancelled = "";
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
args.CancellationToken.Register(() => cancelled = args.Path);
|
||||
};
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
|
||||
|
||||
// Act
|
||||
_ = _router.RunOnNavigateWithRefreshAsync("jan", false);
|
||||
_ = _router.RunOnNavigateWithRefreshAsync("feb", false);
|
||||
_ = _router.RunOnNavigateAsync("jan", false);
|
||||
_ = _router.RunOnNavigateAsync("feb", false);
|
||||
|
||||
// Assert
|
||||
var expected = "jan";
|
||||
|
|
@ -200,7 +150,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
public async Task RefreshesOnceOnCancelledOnNavigateAsync()
|
||||
{
|
||||
// Arrange
|
||||
async Task OnNavigateAsync(NavigationContext args)
|
||||
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
|
||||
{
|
||||
if (args.Path.EndsWith("jan"))
|
||||
{
|
||||
|
|
@ -217,11 +167,11 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
|
|||
}
|
||||
Assert.True(false, "OnUpdateDisplay called more than once.");
|
||||
};
|
||||
_router.OnNavigateAsync = OnNavigateAsync;
|
||||
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
|
||||
|
||||
// Act
|
||||
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
|
||||
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
|
||||
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
|
||||
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
|
||||
|
||||
await jan;
|
||||
await feb;
|
||||
|
|
|
|||
|
|
@ -570,14 +570,24 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
|||
{
|
||||
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
|
||||
|
||||
// Navigating from one page to another should
|
||||
// cancel the previous OnNavigate Task
|
||||
SetUrlViaPushState("/Other");
|
||||
|
||||
var errorUiElem = Browser.Exists(By.Id("blazor-error-ui"), TimeSpan.FromSeconds(10));
|
||||
Assert.NotNull(errorUiElem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnNavigate_CanRenderUIForSyncExceptions()
|
||||
{
|
||||
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
|
||||
|
||||
// Should capture exception from synchronously thrown
|
||||
SetUrlViaPushState("/WithLazyAssembly");
|
||||
|
||||
var errorUiElem = Browser.Exists(By.Id("blazor-error-ui"), TimeSpan.FromSeconds(10));
|
||||
Assert.NotNull(errorUiElem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnNavigate_DoesNotRenderWhileOnNavigateExecuting()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,9 +26,15 @@
|
|||
{ "LongPage1", new Func<NavigationContext, Task>(TestLoadingPageShows) },
|
||||
{ "LongPage2", new Func<NavigationContext, Task>(TestOnNavCancel) },
|
||||
{ "Other", new Func<NavigationContext, Task>(TestOnNavException) },
|
||||
{"WithParameters/name/Abc", new Func<NavigationContext, Task>(TestRefreshHandling)}
|
||||
{ "WithLazyAssembly", new Func<NavigationContext, Task>(TestOnNavException) },
|
||||
{ "WithParameters/name/Abc", new Func<NavigationContext, Task>(TestRefreshHandling) }
|
||||
};
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
Console.WriteLine("Render triggered...");
|
||||
}
|
||||
|
||||
private async Task OnNavigateAsync(NavigationContext args)
|
||||
{
|
||||
Console.WriteLine($"Running OnNavigate for {args.Path}...");
|
||||
|
|
@ -56,6 +62,11 @@
|
|||
throw new Exception("This is an uncaught exception.");
|
||||
}
|
||||
|
||||
public static Task TestOnNavSyncException(NavigationContext args)
|
||||
{
|
||||
throw new Exception("This is an uncaught exception.");
|
||||
}
|
||||
|
||||
public static async Task TestRefreshHandling(NavigationContext args)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, args.CancellationToken);
|
||||
|
|
|
|||
Loading…
Reference in New Issue