Skip to content

Commit 8fc1419

Browse files
authored
Make OnNavigateAsync EventCallback and cancel previous navigation (#25011)
* Make OnNavigateAsync EventCallback and cancel previous navigation * Add more tests
1 parent 5193f8f commit 8fc1419

File tree

4 files changed

+69
-124
lines changed

4 files changed

+69
-124
lines changed

src/Components/Components/src/Routing/Router.cs

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ static readonly ReadOnlyDictionary<string, object> _emptyParametersDictionary
7373
/// <summary>
7474
/// Gets or sets a handler that should be called before navigating to a new page.
7575
/// </summary>
76-
[Parameter] public Func<NavigationContext, Task>? OnNavigateAsync { get; set; }
76+
[Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
7777

7878
private RouteTable Routes { get; set; }
7979

@@ -115,8 +115,7 @@ public async Task SetParametersAsync(ParameterView parameters)
115115
if (!_onNavigateCalled)
116116
{
117117
_onNavigateCalled = true;
118-
await RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
119-
return;
118+
await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
120119
}
121120

122121
Refresh(isNavigationIntercepted: false);
@@ -206,9 +205,8 @@ internal virtual void Refresh(bool isNavigationIntercepted)
206205
}
207206
}
208207

209-
private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNavigate)
208+
internal async ValueTask RunOnNavigateAsync(string path, bool isNavigationIntercepted)
210209
{
211-
212210
// Cancel the CTS instead of disposing it, since disposing does not
213211
// actually cancel and can cause unintended Object Disposed Exceptions.
214212
// This effectivelly cancels the previously running task and completes it.
@@ -217,67 +215,43 @@ private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNav
217215
// before starting the next one. This avoid race conditions where the cancellation
218216
// for the previous task was set but not fully completed by the time we get to this
219217
// invocation.
220-
await previousOnNavigate;
218+
await _previousOnNavigateTask;
221219

222-
if (OnNavigateAsync == null)
220+
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
221+
_previousOnNavigateTask = tcs.Task;
222+
223+
if (!OnNavigateAsync.HasDelegate)
223224
{
224-
return true;
225+
Refresh(isNavigationIntercepted);
225226
}
226227

227228
_onNavigateCts = new CancellationTokenSource();
228229
var navigateContext = new NavigationContext(path, _onNavigateCts.Token);
229230

231+
var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
232+
navigateContext.CancellationToken.Register(state =>
233+
((TaskCompletionSource)state).SetResult(), cancellationTcs);
234+
230235
try
231236
{
232-
if (Navigating != null)
233-
{
234-
_renderHandle.Render(Navigating);
235-
}
236-
await OnNavigateAsync(navigateContext);
237-
return true;
238-
}
239-
catch (OperationCanceledException e)
240-
{
241-
if (e.CancellationToken != navigateContext.CancellationToken)
242-
{
243-
var rethrownException = new InvalidOperationException("OnNavigateAsync can only be cancelled via NavigateContext.CancellationToken.", e);
244-
_renderHandle.Render(builder => ExceptionDispatchInfo.Throw(rethrownException));
245-
}
237+
// Task.WhenAny returns a Task<Task> so we need to await twice to unwrap the exception
238+
var task = await Task.WhenAny(OnNavigateAsync.InvokeAsync(navigateContext), cancellationTcs.Task);
239+
await task;
240+
tcs.SetResult();
241+
Refresh(isNavigationIntercepted);
246242
}
247243
catch (Exception e)
248244
{
249245
_renderHandle.Render(builder => ExceptionDispatchInfo.Throw(e));
250246
}
251-
252-
return false;
253-
}
254-
255-
internal async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
256-
{
257-
// We cache the Task representing the previously invoked RunOnNavigateWithRefreshAsync
258-
// that is stored. Then we create a new one that represents our current invocation and store it
259-
// globally for the next invocation. This allows us to check inside `RunOnNavigateAsync` if the
260-
// previous OnNavigateAsync task has fully completed before starting the next one.
261-
var previousTask = _previousOnNavigateTask;
262-
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
263-
_previousOnNavigateTask = tcs.Task;
264-
265-
// And pass an indicator for the previous task to the currently running one.
266-
var shouldRefresh = await RunOnNavigateAsync(path, previousTask);
267-
tcs.SetResult();
268-
if (shouldRefresh)
269-
{
270-
Refresh(isNavigationIntercepted);
271-
}
272-
273247
}
274248

275249
private void OnLocationChanged(object sender, LocationChangedEventArgs args)
276250
{
277251
_locationAbsolute = args.Location;
278252
if (_renderHandle.IsInitialized && Routes != null)
279253
{
280-
_ = RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted);
254+
_ = RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted);
281255
}
282256
}
283257

src/Components/Components/test/Routing/RouterTest.cs

Lines changed: 26 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
using Microsoft.Extensions.DependencyInjection;
1111
using Microsoft.Extensions.Logging;
1212
using Microsoft.Extensions.Logging.Abstractions;
13-
using Moq;
1413
using Xunit;
15-
using Microsoft.AspNetCore.Components;
1614

1715
namespace Microsoft.AspNetCore.Components.Test.Routing
1816
{
@@ -42,109 +40,61 @@ public async Task CanRunOnNavigateAsync()
4240
{
4341
// Arrange
4442
var called = false;
45-
async Task OnNavigateAsync(NavigationContext args)
43+
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
4644
{
4745
await Task.CompletedTask;
4846
called = true;
49-
}
50-
_router.OnNavigateAsync = OnNavigateAsync;
51-
52-
// Act
53-
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
54-
55-
// Assert
56-
Assert.True(called);
57-
}
58-
59-
[Fact]
60-
public async Task CanHandleSingleFailedOnNavigateAsync()
61-
{
62-
// Arrange
63-
var called = false;
64-
async Task OnNavigateAsync(NavigationContext args)
65-
{
66-
called = true;
67-
await Task.CompletedTask;
68-
throw new Exception("This is an uncaught exception.");
69-
}
70-
_router.OnNavigateAsync = OnNavigateAsync;
47+
};
48+
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
7149

7250
// Act
73-
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
51+
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
7452

7553
// Assert
7654
Assert.True(called);
77-
Assert.Single(_renderer.HandledExceptions);
78-
var unhandledException = _renderer.HandledExceptions[0];
79-
Assert.Equal("This is an uncaught exception.", unhandledException.Message);
8055
}
8156

8257
[Fact]
8358
public async Task CanceledFailedOnNavigateAsyncDoesNothing()
8459
{
8560
// Arrange
8661
var onNavigateInvoked = 0;
87-
async Task OnNavigateAsync(NavigationContext args)
62+
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
8863
{
8964
onNavigateInvoked += 1;
9065
if (args.Path.EndsWith("jan"))
9166
{
9267
await Task.Delay(Timeout.Infinite, args.CancellationToken);
9368
throw new Exception("This is an uncaught exception.");
9469
}
95-
}
96-
var refreshCalled = false;
70+
};
71+
var refreshCalled = 0;
9772
_renderer.OnUpdateDisplay = (renderBatch) =>
9873
{
99-
if (!refreshCalled)
100-
{
101-
refreshCalled = true;
102-
return;
103-
}
104-
Assert.True(false, "OnUpdateDisplay called more than once.");
74+
refreshCalled += 1;
75+
return;
10576
};
106-
_router.OnNavigateAsync = OnNavigateAsync;
77+
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
10778

10879
// Act
109-
var janTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
110-
var febTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
80+
var janTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
81+
var febTask = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
11182

11283
await janTask;
11384
await febTask;
11485

11586
// Assert that we render the second route component and don't throw an exception
11687
Assert.Empty(_renderer.HandledExceptions);
11788
Assert.Equal(2, onNavigateInvoked);
118-
}
119-
120-
[Fact]
121-
public async Task CanHandleSingleCancelledOnNavigateAsync()
122-
{
123-
// Arrange
124-
async Task OnNavigateAsync(NavigationContext args)
125-
{
126-
var tcs = new TaskCompletionSource<int>();
127-
tcs.TrySetCanceled();
128-
await tcs.Task;
129-
}
130-
_renderer.OnUpdateDisplay = (renderBatch) => Assert.True(false, "OnUpdateDisplay called more than once.");
131-
_router.OnNavigateAsync = OnNavigateAsync;
132-
133-
// Act
134-
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
135-
136-
// Assert
137-
Assert.Single(_renderer.HandledExceptions);
138-
var unhandledException = _renderer.HandledExceptions[0];
139-
Assert.Equal("OnNavigateAsync can only be cancelled via NavigateContext.CancellationToken.", unhandledException.Message);
89+
Assert.Equal(2, refreshCalled);
14090
}
14191

14292
[Fact]
14393
public async Task AlreadyCanceledOnNavigateAsyncDoesNothing()
14494
{
14595
// Arrange
14696
var triggerCancel = new TaskCompletionSource();
147-
async Task OnNavigateAsync(NavigationContext args)
97+
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
14898
{
14999
if (args.Path.EndsWith("jan"))
150100
{
@@ -153,7 +103,7 @@ async Task OnNavigateAsync(NavigationContext args)
153103
tcs.TrySetCanceled();
154104
await tcs.Task;
155105
}
156-
}
106+
};
157107
var refreshCalled = false;
158108
_renderer.OnUpdateDisplay = (renderBatch) =>
159109
{
@@ -164,11 +114,11 @@ async Task OnNavigateAsync(NavigationContext args)
164114
}
165115
Assert.True(false, "OnUpdateDisplay called more than once.");
166116
};
167-
_router.OnNavigateAsync = OnNavigateAsync;
117+
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
168118

169119
// Act (start the operations then await them)
170-
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
171-
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
120+
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
121+
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
172122
triggerCancel.TrySetResult();
173123

174124
await jan;
@@ -180,16 +130,16 @@ public void CanCancelPreviousOnNavigateAsync()
180130
{
181131
// Arrange
182132
var cancelled = "";
183-
async Task OnNavigateAsync(NavigationContext args)
133+
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
184134
{
185135
await Task.CompletedTask;
186136
args.CancellationToken.Register(() => cancelled = args.Path);
187137
};
188-
_router.OnNavigateAsync = OnNavigateAsync;
138+
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
189139

190140
// Act
191-
_ = _router.RunOnNavigateWithRefreshAsync("jan", false);
192-
_ = _router.RunOnNavigateWithRefreshAsync("feb", false);
141+
_ = _router.RunOnNavigateAsync("jan", false);
142+
_ = _router.RunOnNavigateAsync("feb", false);
193143

194144
// Assert
195145
var expected = "jan";
@@ -200,7 +150,7 @@ async Task OnNavigateAsync(NavigationContext args)
200150
public async Task RefreshesOnceOnCancelledOnNavigateAsync()
201151
{
202152
// Arrange
203-
async Task OnNavigateAsync(NavigationContext args)
153+
Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
204154
{
205155
if (args.Path.EndsWith("jan"))
206156
{
@@ -217,11 +167,11 @@ async Task OnNavigateAsync(NavigationContext args)
217167
}
218168
Assert.True(false, "OnUpdateDisplay called more than once.");
219169
};
220-
_router.OnNavigateAsync = OnNavigateAsync;
170+
_router.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
221171

222172
// Act
223-
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
224-
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/feb", false));
173+
var jan = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/jan", false));
174+
var feb = _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateAsync("http://example.com/feb", false));
225175

226176
await jan;
227177
await feb;

src/Components/test/E2ETest/Tests/RoutingTest.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,14 +570,24 @@ public void OnNavigate_CanRenderUIForExceptions()
570570
{
571571
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
572572

573-
// Navigating from one page to another should
574-
// cancel the previous OnNavigate Task
575573
SetUrlViaPushState("/Other");
576574

577575
var errorUiElem = Browser.Exists(By.Id("blazor-error-ui"), TimeSpan.FromSeconds(10));
578576
Assert.NotNull(errorUiElem);
579577
}
580578

579+
[Fact]
580+
public void OnNavigate_CanRenderUIForSyncExceptions()
581+
{
582+
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
583+
584+
// Should capture exception from synchronously thrown
585+
SetUrlViaPushState("/WithLazyAssembly");
586+
587+
var errorUiElem = Browser.Exists(By.Id("blazor-error-ui"), TimeSpan.FromSeconds(10));
588+
Assert.NotNull(errorUiElem);
589+
}
590+
581591
[Fact]
582592
public void OnNavigate_DoesNotRenderWhileOnNavigateExecuting()
583593
{

src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@
2626
{ "LongPage1", new Func<NavigationContext, Task>(TestLoadingPageShows) },
2727
{ "LongPage2", new Func<NavigationContext, Task>(TestOnNavCancel) },
2828
{ "Other", new Func<NavigationContext, Task>(TestOnNavException) },
29-
{"WithParameters/name/Abc", new Func<NavigationContext, Task>(TestRefreshHandling)}
29+
{ "WithLazyAssembly", new Func<NavigationContext, Task>(TestOnNavException) },
30+
{ "WithParameters/name/Abc", new Func<NavigationContext, Task>(TestRefreshHandling) }
3031
};
3132

33+
protected override void OnAfterRender(bool firstRender)
34+
{
35+
Console.WriteLine("Render triggered...");
36+
}
37+
3238
private async Task OnNavigateAsync(NavigationContext args)
3339
{
3440
Console.WriteLine($"Running OnNavigate for {args.Path}...");
@@ -56,6 +62,11 @@
5662
throw new Exception("This is an uncaught exception.");
5763
}
5864

65+
public static Task TestOnNavSyncException(NavigationContext args)
66+
{
67+
throw new Exception("This is an uncaught exception.");
68+
}
69+
5970
public static async Task TestRefreshHandling(NavigationContext args)
6071
{
6172
await Task.Delay(Timeout.Infinite, args.CancellationToken);

0 commit comments

Comments
 (0)