Skip to content

Commit 0a61165

Browse files
authored
Tests, tweaks, and other follow-ups to lazy-loading (#23947)
* Render 'OnNavigateError' fragment on unhandled exception in OnNavigateAsync * Address first round of feedback from peer review * Refactor OnNavigateAsync handling and fix tests * Make OnNavigateAsync cancellation cooperative with user tasks * Fix aggressive re-rendering and cancellation handling * Fix up tests based on peer review
1 parent e822f5f commit 0a61165

File tree

4 files changed

+239
-61
lines changed

4 files changed

+239
-61
lines changed

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

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#nullable disable warnings
55

66
using System;
7+
using System.Runtime.ExceptionServices;
78
using System.Collections.Generic;
89
using System.Collections.ObjectModel;
910
using System.Linq;
@@ -72,7 +73,7 @@ static readonly ReadOnlyDictionary<string, object> _emptyParametersDictionary
7273
/// <summary>
7374
/// Gets or sets a handler that should be called before navigating to a new page.
7475
/// </summary>
75-
[Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
76+
[Parameter] public Func<NavigationContext, Task> OnNavigateAsync { get; set; }
7677

7778
private RouteTable Routes { get; set; }
7879

@@ -194,51 +195,58 @@ internal virtual void Refresh(bool isNavigationIntercepted)
194195

195196
private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNavigate)
196197
{
197-
// If this router instance does not provide an OnNavigateAsync parameter
198-
// then we render the component associated with the route as per usual.
199-
if (!OnNavigateAsync.HasDelegate)
198+
if (OnNavigateAsync == null)
200199
{
201200
return true;
202201
}
203202

204-
// If we've already invoked a task and stored its CTS, then
205-
// cancel that existing CTS.
203+
// Cancel the CTS instead of disposing it, since disposing does not
204+
// actually cancel and can cause unintended Object Disposed Exceptions.
205+
// This effectivelly cancels the previously running task and completes it.
206206
_onNavigateCts?.Cancel();
207-
// Then make sure that the task has been completed cancelled or
208-
// completed before continuing with the execution of this current task.
207+
// Then make sure that the task has been completely cancelled or completed
208+
// before starting the next one. This avoid race conditions where the cancellation
209+
// for the previous task was set but not fully completed by the time we get to this
210+
// invocation.
209211
await previousOnNavigate;
210212

211-
// Create a new cancellation token source for this instance
212213
_onNavigateCts = new CancellationTokenSource();
213214
var navigateContext = new NavigationContext(path, _onNavigateCts.Token);
214215

215-
// Create a cancellation task based on the cancellation token
216-
// associated with the current running task.
217-
var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
218-
navigateContext.CancellationToken.Register(state =>
219-
((TaskCompletionSource)state).SetResult(), cancellationTcs);
220-
221-
var task = OnNavigateAsync.InvokeAsync(navigateContext);
222-
223-
// If the user provided a Navigating render fragment, then show it.
224-
if (Navigating != null && task.Status != TaskStatus.RanToCompletion)
216+
try
217+
{
218+
if (Navigating != null)
219+
{
220+
_renderHandle.Render(Navigating);
221+
}
222+
await OnNavigateAsync(navigateContext);
223+
return true;
224+
}
225+
catch (OperationCanceledException e)
226+
{
227+
if (e.CancellationToken != navigateContext.CancellationToken)
228+
{
229+
var rethrownException = new InvalidOperationException("OnNavigateAsync can only be cancelled via NavigateContext.CancellationToken.", e);
230+
_renderHandle.Render(builder => ExceptionDispatchInfo.Capture(rethrownException).Throw());
231+
return false;
232+
}
233+
}
234+
catch (Exception e)
225235
{
226-
_renderHandle.Render(Navigating);
236+
_renderHandle.Render(builder => ExceptionDispatchInfo.Capture(e).Throw());
237+
return false;
227238
}
228239

229-
var completedTask = await Task.WhenAny(task, cancellationTcs.Task);
230-
return task == completedTask;
240+
return false;
231241
}
232242

233243
internal async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
234244
{
235245
// We cache the Task representing the previously invoked RunOnNavigateWithRefreshAsync
236-
// that is stored
246+
// that is stored. Then we create a new one that represents our current invocation and store it
247+
// globally for the next invocation. This allows us to check inside `RunOnNavigateAsync` if the
248+
// previous OnNavigateAsync task has fully completed before starting the next one.
237249
var previousTask = _previousOnNavigateTask;
238-
// Then we create a new one that represents our current invocation and store it
239-
// globally for the next invocation. Note to the developer, if the WASM runtime
240-
// support multi-threading then we'll need to implement the appropriate locks
241-
// here to ensure that the cached previous task is overwritten incorrectly.
242250
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
243251
_previousOnNavigateTask = tcs.Task;
244252
try

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

Lines changed: 183 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,103 +2,253 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
6-
using System.Linq;
75
using System.Reflection;
86
using System.Threading;
97
using System.Threading.Tasks;
10-
using Microsoft.AspNetCore.Components;
118
using Microsoft.AspNetCore.Components.Routing;
129
using Microsoft.AspNetCore.Components.Test.Helpers;
13-
using Microsoft.Extensions.DependencyModel;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Logging.Abstractions;
1413
using Moq;
1514
using Xunit;
15+
using Microsoft.AspNetCore.Components;
1616

1717
namespace Microsoft.AspNetCore.Components.Test.Routing
1818
{
1919
public class RouterTest
2020
{
21+
private readonly Router _router;
22+
private readonly TestRenderer _renderer;
23+
24+
public RouterTest()
25+
{
26+
var services = new ServiceCollection();
27+
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
28+
services.AddSingleton<NavigationManager, TestNavigationManager>();
29+
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
30+
var serviceProvider = services.BuildServiceProvider();
31+
32+
_renderer = new TestRenderer(serviceProvider);
33+
_renderer.ShouldHandleExceptions = true;
34+
_router = (Router)_renderer.InstantiateComponent<Router>();
35+
_router.AppAssembly = Assembly.GetExecutingAssembly();
36+
_router.Found = routeData => (builder) => builder.AddContent(0, "Rendering route...");
37+
_renderer.AssignRootComponentId(_router);
38+
}
39+
2140
[Fact]
2241
public async Task CanRunOnNavigateAsync()
2342
{
2443
// Arrange
25-
var router = CreateMockRouter();
2644
var called = false;
2745
async Task OnNavigateAsync(NavigationContext args)
2846
{
2947
await Task.CompletedTask;
3048
called = true;
3149
}
32-
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
50+
_router.OnNavigateAsync = OnNavigateAsync;
3351

3452
// Act
35-
await router.Object.RunOnNavigateWithRefreshAsync("http://example.com/jan", false);
53+
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
3654

3755
// Assert
3856
Assert.True(called);
3957
}
4058

4159
[Fact]
42-
public async Task CanCancelPreviousOnNavigateAsync()
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;
71+
72+
// Act
73+
await _renderer.Dispatcher.InvokeAsync(() => _router.RunOnNavigateWithRefreshAsync("http://example.com/jan", false));
74+
75+
// Assert
76+
Assert.True(called);
77+
Assert.Single(_renderer.HandledExceptions);
78+
var unhandledException = _renderer.HandledExceptions[0];
79+
Assert.Equal("This is an uncaught exception.", unhandledException.Message);
80+
}
81+
82+
[Fact]
83+
public async Task CanceledFailedOnNavigateAsyncDoesNothing()
84+
{
85+
// Arrange
86+
var onNavigateInvoked = 0;
87+
async Task OnNavigateAsync(NavigationContext args)
88+
{
89+
onNavigateInvoked += 1;
90+
if (args.Path.EndsWith("jan"))
91+
{
92+
await Task.Delay(Timeout.Infinite, args.CancellationToken);
93+
throw new Exception("This is an uncaught exception.");
94+
}
95+
}
96+
var refreshCalled = false;
97+
_renderer.OnUpdateDisplay = (renderBatch) =>
98+
{
99+
if (!refreshCalled)
100+
{
101+
refreshCalled = true;
102+
return;
103+
}
104+
Assert.True(false, "OnUpdateDisplay called more than once.");
105+
};
106+
_router.OnNavigateAsync = OnNavigateAsync;
107+
108+
// 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));
111+
112+
await janTask;
113+
await febTask;
114+
115+
// Assert that we render the second route component and don't throw an exception
116+
Assert.Empty(_renderer.HandledExceptions);
117+
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);
140+
}
141+
142+
[Fact]
143+
public async Task AlreadyCanceledOnNavigateAsyncDoesNothing()
144+
{
145+
// Arrange
146+
var triggerCancel = new TaskCompletionSource();
147+
async Task OnNavigateAsync(NavigationContext args)
148+
{
149+
if (args.Path.EndsWith("jan"))
150+
{
151+
var tcs = new TaskCompletionSource();
152+
await triggerCancel.Task;
153+
tcs.TrySetCanceled();
154+
await tcs.Task;
155+
}
156+
}
157+
var refreshCalled = false;
158+
_renderer.OnUpdateDisplay = (renderBatch) =>
159+
{
160+
if (!refreshCalled)
161+
{
162+
Assert.True(true);
163+
return;
164+
}
165+
Assert.True(false, "OnUpdateDisplay called more than once.");
166+
};
167+
_router.OnNavigateAsync = OnNavigateAsync;
168+
169+
// 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));
172+
triggerCancel.TrySetResult();
173+
174+
await jan;
175+
await feb;
176+
}
177+
178+
[Fact]
179+
public void CanCancelPreviousOnNavigateAsync()
43180
{
44181
// Arrange
45-
var router = CreateMockRouter();
46182
var cancelled = "";
47183
async Task OnNavigateAsync(NavigationContext args)
48184
{
49185
await Task.CompletedTask;
50186
args.CancellationToken.Register(() => cancelled = args.Path);
51187
};
52-
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
188+
_router.OnNavigateAsync = OnNavigateAsync;
53189

54190
// Act
55-
await router.Object.RunOnNavigateWithRefreshAsync("jan", false);
56-
await router.Object.RunOnNavigateWithRefreshAsync("feb", false);
191+
_ = _router.RunOnNavigateWithRefreshAsync("jan", false);
192+
_ = _router.RunOnNavigateWithRefreshAsync("feb", false);
57193

58194
// Assert
59195
var expected = "jan";
60-
Assert.Equal(cancelled, expected);
196+
Assert.Equal(expected, cancelled);
61197
}
62198

63199
[Fact]
64200
public async Task RefreshesOnceOnCancelledOnNavigateAsync()
65201
{
66202
// Arrange
67-
var router = CreateMockRouter();
68203
async Task OnNavigateAsync(NavigationContext args)
69204
{
70205
if (args.Path.EndsWith("jan"))
71206
{
72-
await Task.Delay(Timeout.Infinite);
207+
await Task.Delay(Timeout.Infinite, args.CancellationToken);
208+
}
209+
};
210+
var refreshCalled = false;
211+
_renderer.OnUpdateDisplay = (renderBatch) =>
212+
{
213+
if (!refreshCalled)
214+
{
215+
Assert.True(true);
216+
return;
73217
}
218+
Assert.True(false, "OnUpdateDisplay called more than once.");
74219
};
75-
router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
220+
_router.OnNavigateAsync = OnNavigateAsync;
76221

77222
// Act
78-
var janTask = router.Object.RunOnNavigateWithRefreshAsync("jan", false);
79-
var febTask = router.Object.RunOnNavigateWithRefreshAsync("feb", false);
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));
80225

81-
var janTaskException = await Record.ExceptionAsync(() => janTask);
82-
var febTaskException = await Record.ExceptionAsync(() => febTask);
83-
84-
// Assert neither exceution threw an exception
85-
Assert.Null(janTaskException);
86-
Assert.Null(febTaskException);
87-
// Assert refresh should've only been called once for the second route
88-
router.Verify(x => x.Refresh(false), Times.Once());
226+
await jan;
227+
await feb;
89228
}
90229

91-
private Mock<Router> CreateMockRouter()
230+
internal class TestNavigationManager : NavigationManager
92231
{
93-
var router = new Mock<Router>() { CallBase = true };
94-
router.Setup(x => x.Refresh(It.IsAny<bool>())).Verifiable();
95-
return router;
232+
public TestNavigationManager() =>
233+
Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan");
234+
235+
protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotImplementedException();
96236
}
97237

98-
[Route("jan")]
99-
private class JanComponent : ComponentBase { }
238+
internal sealed class TestNavigationInterception : INavigationInterception
239+
{
240+
public static readonly TestNavigationInterception Instance = new TestNavigationInterception();
241+
242+
public Task EnableNavigationInterceptionAsync()
243+
{
244+
return Task.CompletedTask;
245+
}
246+
}
100247

101248
[Route("feb")]
102-
private class FebComponent : ComponentBase { }
249+
public class FebComponent : ComponentBase { }
250+
251+
[Route("jan")]
252+
public class JanComponent : ComponentBase { }
103253
}
104254
}

0 commit comments

Comments
 (0)