Skip to content

Commit 0e4658c

Browse files
[release/10.0-preview7] Support all subscribers to OnNotFound event (#62835)
* Support application subscribing to `OnNotFound` and setting `NotFoundEventArgs.Path`. * Path from args has higher priority than `NotFoundPage`. * Fix build. * Add tests + throw if path does not match any component. --------- Co-authored-by: Ilona Tomkowicz <[email protected]>
1 parent d6a0582 commit 0e4658c

File tree

4 files changed

+314
-8
lines changed

4 files changed

+314
-8
lines changed

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,13 +391,55 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
391391

392392
private void OnNotFound(object sender, NotFoundEventArgs args)
393393
{
394-
if (_renderHandle.IsInitialized && NotFoundPage != null)
394+
bool renderContentIsProvided = NotFoundPage != null || args.Path != null;
395+
if (_renderHandle.IsInitialized && renderContentIsProvided)
395396
{
396-
// setting the path signals to the endpoint renderer that router handled rendering
397-
args.Path = _notFoundPageRoute;
398-
Log.DisplayingNotFound(_logger);
399-
RenderNotFound();
397+
if (!string.IsNullOrEmpty(args.Path))
398+
{
399+
// The path can be set by a subscriber not defined in blazor framework.
400+
_renderHandle.Render(builder => RenderComponentByRoute(builder, args.Path));
401+
}
402+
else
403+
{
404+
// Having the path set signals to the endpoint renderer that router handled rendering.
405+
args.Path = _notFoundPageRoute;
406+
RenderNotFound();
407+
}
408+
Log.DisplayingNotFound(_logger, args.Path);
409+
}
410+
}
411+
412+
internal void RenderComponentByRoute(RenderTreeBuilder builder, string route)
413+
{
414+
var componentType = FindComponentTypeByRoute(route);
415+
416+
if (componentType is null)
417+
{
418+
throw new InvalidOperationException($"No component found for route '{route}'. " +
419+
$"Ensure the route matches a component with a [Route] attribute.");
400420
}
421+
422+
builder.OpenComponent<RouteView>(0);
423+
builder.AddAttribute(1, nameof(RouteView.RouteData),
424+
new RouteData(componentType, new Dictionary<string, object>()));
425+
builder.CloseComponent();
426+
}
427+
428+
[return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
429+
internal Type? FindComponentTypeByRoute(string route)
430+
{
431+
RefreshRouteTable();
432+
var normalizedRoute = route.StartsWith('/') ? route : $"/{route}";
433+
434+
var context = new RouteContext(normalizedRoute);
435+
Routes.Route(context);
436+
437+
if (context.Handler is not null && typeof(IComponent).IsAssignableFrom(context.Handler))
438+
{
439+
return context.Handler;
440+
}
441+
442+
return null;
401443
}
402444

403445
private void RenderNotFound()
@@ -451,8 +493,8 @@ private static partial class Log
451493
[LoggerMessage(3, LogLevel.Debug, "Navigating to non-component URI '{ExternalUri}' in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToExternalUri")]
452494
internal static partial void NavigatingToExternalUri(ILogger logger, string externalUri, string path, string baseUri);
453495

454-
[LoggerMessage(4, LogLevel.Debug, $"Displaying {nameof(NotFound)} on request", EventName = "DisplayingNotFoundOnRequest")]
455-
internal static partial void DisplayingNotFound(ILogger logger);
496+
[LoggerMessage(4, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on request", EventName = "DisplayingNotFoundOnRequest")]
497+
internal static partial void DisplayingNotFound(ILogger logger, string displayedContentPath);
456498
#pragma warning restore CS0618 // Type or member is obsolete
457499
}
458500
}

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

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
using System.Reflection;
77
using Microsoft.AspNetCore.Components.RenderTree;
8+
using Microsoft.AspNetCore.Components.Rendering;
89
using Microsoft.AspNetCore.Components.Test.Helpers;
910
using Microsoft.Extensions.DependencyInjection;
1011
using Microsoft.Extensions.Logging;
@@ -301,6 +302,192 @@ await renderer.Dispatcher.InvokeAsync(() =>
301302
Assert.Contains("Use either NotFound or NotFoundPage", exception.Message);
302303
}
303304

305+
[Fact]
306+
public async Task OnNotFound_WithNotFoundPageSet_UsesNotFoundPage()
307+
{
308+
// Create a new router instance for this test to control Attach() timing
309+
var services = new ServiceCollection();
310+
var testNavManager = new TestNavigationManager();
311+
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
312+
services.AddSingleton<NavigationManager>(testNavManager);
313+
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
314+
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
315+
var serviceProvider = services.BuildServiceProvider();
316+
317+
var testRenderer = new TestRenderer(serviceProvider);
318+
testRenderer.ShouldHandleExceptions = true;
319+
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
320+
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
321+
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");
322+
323+
var parameters = new Dictionary<string, object>
324+
{
325+
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly },
326+
{ nameof(Router.NotFoundPage), typeof(NotFoundTestComponent) }
327+
};
328+
329+
// Assign the root component ID which will call Attach()
330+
testRenderer.AssignRootComponentId(testRouter);
331+
332+
// Act
333+
await testRenderer.Dispatcher.InvokeAsync(() =>
334+
testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters)));
335+
336+
// Trigger the NavigationManager's OnNotFound event
337+
await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound());
338+
339+
// Assert
340+
var lastBatch = testRenderer.Batches.Last();
341+
var renderedFrame = lastBatch.ReferenceFrames.First();
342+
Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType);
343+
Assert.Equal(typeof(RouteView), renderedFrame.ComponentType);
344+
345+
// Verify that the RouteData contains the NotFoundTestComponent
346+
var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First();
347+
Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType);
348+
var routeData = (RouteData)routeViewFrame.AttributeValue;
349+
Assert.Equal(typeof(NotFoundTestComponent), routeData.PageType);
350+
}
351+
352+
[Fact]
353+
public async Task OnNotFound_WithArgsPathSet_RendersComponentByRoute()
354+
{
355+
// Create a new router instance for this test to control Attach() timing
356+
var services = new ServiceCollection();
357+
var testNavManager = new TestNavigationManager();
358+
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
359+
services.AddSingleton<NavigationManager>(testNavManager);
360+
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
361+
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
362+
var serviceProvider = services.BuildServiceProvider();
363+
364+
var testRenderer = new TestRenderer(serviceProvider);
365+
testRenderer.ShouldHandleExceptions = true;
366+
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
367+
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
368+
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");
369+
370+
var parameters = new Dictionary<string, object>
371+
{
372+
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly }
373+
};
374+
375+
// Subscribe to OnNotFound event BEFORE router attaches and set args.Path
376+
testNavManager.OnNotFound += (sender, args) =>
377+
{
378+
args.Path = "/jan"; // Point to an existing route
379+
};
380+
381+
// Assign the root component ID which will call Attach()
382+
testRenderer.AssignRootComponentId(testRouter);
383+
384+
// Act
385+
await testRenderer.Dispatcher.InvokeAsync(() =>
386+
testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters)));
387+
388+
// Trigger the NavigationManager's OnNotFound event
389+
await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound());
390+
391+
// Assert
392+
var lastBatch = testRenderer.Batches.Last();
393+
var renderedFrame = lastBatch.ReferenceFrames.First();
394+
Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType);
395+
Assert.Equal(typeof(RouteView), renderedFrame.ComponentType);
396+
397+
// Verify that the RouteData contains the correct component type
398+
var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First();
399+
Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType);
400+
var routeData = (RouteData)routeViewFrame.AttributeValue;
401+
Assert.Equal(typeof(JanComponent), routeData.PageType);
402+
}
403+
404+
[Fact]
405+
public async Task OnNotFound_WithBothNotFoundPageAndArgsPath_PreferArgs()
406+
{
407+
// Create a new router instance for this test to control Attach() timing
408+
var services = new ServiceCollection();
409+
var testNavManager = new TestNavigationManager();
410+
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
411+
services.AddSingleton<NavigationManager>(testNavManager);
412+
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
413+
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
414+
var serviceProvider = services.BuildServiceProvider();
415+
416+
var testRenderer = new TestRenderer(serviceProvider);
417+
testRenderer.ShouldHandleExceptions = true;
418+
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
419+
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
420+
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");
421+
422+
var parameters = new Dictionary<string, object>
423+
{
424+
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly },
425+
{ nameof(Router.NotFoundPage), typeof(NotFoundTestComponent) }
426+
};
427+
428+
// Subscribe to OnNotFound event BEFORE router attaches and sets up its own subscription
429+
testNavManager.OnNotFound += (sender, args) =>
430+
{
431+
args.Path = "/jan"; // This should take precedence over NotFoundPage
432+
};
433+
434+
// Now assign the root component ID which will call Attach()
435+
testRenderer.AssignRootComponentId(testRouter);
436+
437+
await testRenderer.Dispatcher.InvokeAsync(() =>
438+
testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters)));
439+
440+
// trigger the NavigationManager's OnNotFound event
441+
await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound());
442+
443+
// The Router should have rendered using RenderComponentByRoute (args.Path) instead of NotFoundPage
444+
var lastBatch = testRenderer.Batches.Last();
445+
var renderedFrame = lastBatch.ReferenceFrames.First();
446+
Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType);
447+
Assert.Equal(typeof(RouteView), renderedFrame.ComponentType);
448+
449+
// Verify that the RouteData contains the JanComponent (from args.Path), not NotFoundTestComponent
450+
var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First();
451+
Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType);
452+
var routeData = (RouteData)routeViewFrame.AttributeValue;
453+
Assert.Equal(typeof(JanComponent), routeData.PageType);
454+
}
455+
456+
[Fact]
457+
public async Task FindComponentTypeByRoute_WithValidRoute_ReturnsComponentType()
458+
{
459+
var parameters = new Dictionary<string, object>
460+
{
461+
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly }
462+
};
463+
464+
await _renderer.Dispatcher.InvokeAsync(() =>
465+
_router.SetParametersAsync(ParameterView.FromDictionary(parameters)));
466+
467+
var result = _router.FindComponentTypeByRoute("/jan");
468+
Assert.Equal(typeof(JanComponent), result);
469+
}
470+
471+
[Fact]
472+
public async Task RenderComponentByRoute_WithInvalidRoute_ThrowsException()
473+
{
474+
var parameters = new Dictionary<string, object>
475+
{
476+
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly }
477+
};
478+
479+
await _renderer.Dispatcher.InvokeAsync(() =>
480+
_router.SetParametersAsync(ParameterView.FromDictionary(parameters)));
481+
482+
var builder = new RenderTreeBuilder();
483+
484+
var exception = Assert.Throws<InvalidOperationException>(() =>
485+
{
486+
_router.RenderComponentByRoute(builder, "/nonexistent-route");
487+
});
488+
Assert.Contains("No component found for route '/nonexistent-route'", exception.Message);
489+
}
490+
304491
internal class TestNavigationManager : NavigationManager
305492
{
306493
public TestNavigationManager() =>
@@ -311,6 +498,11 @@ public void NotifyLocationChanged(string uri, bool intercepted, string state = n
311498
Uri = uri;
312499
NotifyLocationChanged(intercepted);
313500
}
501+
502+
public void TriggerNotFound()
503+
{
504+
base.NotFound();
505+
}
314506
}
315507

316508
internal sealed class TestNavigationInterception : INavigationInterception

src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ private void AssertBrowserDefaultNotFoundViewRendered()
138138
);
139139
}
140140

141+
private void AssertLandingPageRendered() =>
142+
Browser.Equal("Any content", () => Browser.Exists(By.Id("test-info")).Text);
143+
141144
private void AssertNotFoundPageRendered()
142145
{
143146
Browser.Equal("Welcome On Custom Not Found Page", () => Browser.FindElement(By.Id("test-info")).Text);
@@ -183,6 +186,30 @@ public void NotFoundSetOnInitialization_ResponseNotStarted_SSR(bool hasReExecuti
183186
AssertUrlNotChanged(testUrl);
184187
}
185188

189+
[Theory]
190+
[InlineData(true, true)]
191+
[InlineData(true, false)]
192+
[InlineData(false, true)]
193+
[InlineData(false, false)]
194+
// This tests the application subscribing to OnNotFound event and setting NotFoundEventArgs.Path, opposed to the framework doing it for the app.
195+
public void NotFoundSetOnInitialization_ApplicationSubscribesToNotFoundEventToSetNotFoundPath_SSR (bool streaming, bool customRouter)
196+
{
197+
string streamingPath = streaming ? "-streaming" : "";
198+
string testUrl = $"{ServerPathBase}/set-not-found-ssr{streamingPath}?useCustomRouter={customRouter}&appSetsEventArgsPath=true";
199+
Navigate(testUrl);
200+
201+
bool onlyReExecutionCouldRenderNotFoundPage = !streaming && customRouter;
202+
if (onlyReExecutionCouldRenderNotFoundPage)
203+
{
204+
AssertLandingPageRendered();
205+
}
206+
else
207+
{
208+
AssertNotFoundPageRendered();
209+
}
210+
AssertUrlNotChanged(testUrl);
211+
}
212+
186213
[Theory]
187214
[InlineData(true, true)]
188215
[InlineData(true, false)]

src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
@using Components.TestServer.RazorComponents.Pages.Forms
1+
@implements IDisposable
2+
@using Components.TestServer.RazorComponents.Pages.Forms
23
@using Components.WasmMinimal.Pages.NotFound
34
@using TestContentPackage.NotFound
45
@using Components.TestServer.RazorComponents
@@ -12,7 +13,39 @@
1213
[SupplyParameterFromQuery(Name = "useCustomRouter")]
1314
public string? UseCustomRouter { get; set; }
1415

16+
[Parameter]
17+
[SupplyParameterFromQuery(Name = "appSetsEventArgsPath")]
18+
public bool AppSetsEventArgsPath { get; set; }
19+
1520
private Type? NotFoundPageType { get; set; }
21+
private NavigationManager _navigationManager = default!;
22+
23+
[Inject]
24+
private NavigationManager NavigationManager
25+
{
26+
get => _navigationManager;
27+
set
28+
{
29+
_navigationManager = value;
30+
}
31+
}
32+
33+
private void OnNotFoundEvent(object sender, NotFoundEventArgs e)
34+
{
35+
var type = typeof(CustomNotFoundPage);
36+
var routeAttributes = type.GetCustomAttributes(typeof(RouteAttribute), inherit: true);
37+
if (routeAttributes.Length == 0)
38+
{
39+
throw new InvalidOperationException($"The type {type.FullName} " +
40+
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
41+
}
42+
43+
var routeAttribute = (RouteAttribute)routeAttributes[0];
44+
if (routeAttribute.Template != null)
45+
{
46+
e.Path = routeAttribute.Template;
47+
}
48+
}
1649

1750
protected override void OnParametersSet()
1851
{
@@ -24,6 +57,18 @@
2457
{
2558
NotFoundPageType = null;
2659
}
60+
if (AppSetsEventArgsPath && _navigationManager is not null)
61+
{
62+
_navigationManager.OnNotFound += OnNotFoundEvent;
63+
}
64+
}
65+
66+
public void Dispose()
67+
{
68+
if (AppSetsEventArgsPath)
69+
{
70+
_navigationManager.OnNotFound -= OnNotFoundEvent;
71+
}
2772
}
2873
}
2974

0 commit comments

Comments
 (0)