From f842fcf90569c2d187379b47d13021892c00e970 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 18 Jul 2025 12:52:30 +0200 Subject: [PATCH 1/4] Support application subscribing to `OnNotFound` and setting `NotFoundEventArgs.Path`. --- .../Components/src/Routing/Router.cs | 44 +++++++++++++++-- .../NoInteractivityTest.cs | 27 +++++++++++ .../RazorComponents/App.razor | 47 ++++++++++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index fc18c528a95c..1e5dd4d2dafe 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -391,15 +391,53 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args) private void OnNotFound(object sender, NotFoundEventArgs args) { - if (_renderHandle.IsInitialized && NotFoundPage != null) + bool renderContentIsProvided = NotFoundPage != null || args.Path != null; + if (_renderHandle.IsInitialized && renderContentIsProvided) { - // setting the path signals to the endpoint renderer that router handled rendering - args.Path = _notFoundPageRoute; Log.DisplayingNotFound(_logger); + if (NotFoundPage == null && !string.IsNullOrEmpty(args.Path)) + { + // The path can be set by a subscriber not defined in blazor framework. + _renderHandle.Render(builder => RenderComponentByRoute(builder, args.Path)); + return; + } + + // Having the path set signals to the endpoint renderer that router handled rendering. + args.Path = _notFoundPageRoute; RenderNotFound(); } } + private void RenderComponentByRoute(RenderTreeBuilder builder, string route) + { + var componentType = FindComponentTypeByRoute(route); + + if (componentType != null) + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(RouteView.RouteData), + new RouteData(componentType, new Dictionary())); + builder.CloseComponent(); + } + } + + [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] + private Type? FindComponentTypeByRoute(string route) + { + RefreshRouteTable(); + var normalizedRoute = route.StartsWith('/') ? route : $"/{route}"; + + var context = new RouteContext(normalizedRoute); + Routes.Route(context); + + if (context.Handler is not null && typeof(IComponent).IsAssignableFrom(context.Handler)) + { + return context.Handler; + } + + return null; + } + private void RenderNotFound() { _renderHandle.Render(builder => diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 1b3c340084a5..a6421eb94689 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -138,6 +138,9 @@ private void AssertBrowserDefaultNotFoundViewRendered() ); } + private void AssertLandingPageRendered() => + Browser.Equal("Any content", () => Browser.Exists(By.Id("test-info")).Text); + private void AssertNotFoundPageRendered() { 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 AssertUrlNotChanged(testUrl); } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + // This tests the application subscribing to OnNotFound event and setting NotFoundEventArgs.Path, opposed to the framework doing it for the app. + public void NotFoundSetOnInitialization_ApplicationSubscribesToNotFoundEventToSetNotFoundPath_SSR (bool streaming, bool customRouter) + { + string streamingPath = streaming ? "-streaming" : ""; + string testUrl = $"{ServerPathBase}/set-not-found-ssr{streamingPath}?useCustomRouter={customRouter}&appSetsEventArgsPath=true"; + Navigate(testUrl); + + bool onlyReExecutionCouldRenderNotFoundPage = !streaming && customRouter; + if (onlyReExecutionCouldRenderNotFoundPage) + { + AssertLandingPageRendered(); + } + else + { + AssertNotFoundPageRendered(); + } + AssertUrlNotChanged(testUrl); + } + [Theory] [InlineData(true, true)] [InlineData(true, false)] diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index c8a92f2ba9d5..012dcc5547f8 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -1,4 +1,5 @@ -@using Components.TestServer.RazorComponents.Pages.Forms +@implements IDisposable +@using Components.TestServer.RazorComponents.Pages.Forms @using Components.WasmMinimal.Pages.NotFound @using TestContentPackage.NotFound @using Components.TestServer.RazorComponents @@ -12,7 +13,39 @@ [SupplyParameterFromQuery(Name = "useCustomRouter")] public string? UseCustomRouter { get; set; } + [Parameter] + [SupplyParameterFromQuery(Name = "appSetsEventArgsPath")] + public bool AppSetsEventArgsPath { get; set; } + private Type? NotFoundPageType { get; set; } + private NavigationManager _navigationManager = default!; + + [Inject] + private NavigationManager NavigationManager + { + get => _navigationManager; + set + { + _navigationManager = value; + } + } + + private void OnNotFoundEvent(object sender, NotFoundEventArgs e) + { + var type = typeof(CustomNotFoundPage); + var routeAttributes = type.GetCustomAttributes(typeof(RouteAttribute), inherit: true); + if (routeAttributes.Length == 0) + { + throw new InvalidOperationException($"The type {type.FullName} " + + $"does not have a {typeof(RouteAttribute).FullName} applied to it."); + } + + var routeAttribute = (RouteAttribute)routeAttributes[0]; + if (routeAttribute.Template != null) + { + e.Path = routeAttribute.Template; + } + } protected override void OnParametersSet() { @@ -24,6 +57,18 @@ { NotFoundPageType = null; } + if (AppSetsEventArgsPath && _navigationManager is not null) + { + _navigationManager.OnNotFound += OnNotFoundEvent; + } + } + + public void Dispose() + { + if (AppSetsEventArgsPath) + { + _navigationManager.OnNotFound -= OnNotFoundEvent; + } } } From 6da74c719726c04a6bf96c8e373a7001dc0a856e Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 18 Jul 2025 14:50:21 +0200 Subject: [PATCH 2/4] Path from args has higher priority than `NotFoundPage`. --- src/Components/Components/src/Routing/Router.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 1e5dd4d2dafe..609851440f52 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -394,17 +394,18 @@ private void OnNotFound(object sender, NotFoundEventArgs args) bool renderContentIsProvided = NotFoundPage != null || args.Path != null; if (_renderHandle.IsInitialized && renderContentIsProvided) { - Log.DisplayingNotFound(_logger); - if (NotFoundPage == null && !string.IsNullOrEmpty(args.Path)) + if (!string.IsNullOrEmpty(args.Path)) { // The path can be set by a subscriber not defined in blazor framework. _renderHandle.Render(builder => RenderComponentByRoute(builder, args.Path)); - return; } - - // Having the path set signals to the endpoint renderer that router handled rendering. - args.Path = _notFoundPageRoute; - RenderNotFound(); + else + { + // Having the path set signals to the endpoint renderer that router handled rendering. + args.Path = _notFoundPageRoute; + RenderNotFound(); + } + Log.DisplayingNotFound(_logger, args.Path); } } @@ -490,7 +491,7 @@ private static partial class Log internal static partial void NavigatingToExternalUri(ILogger logger, string externalUri, string path, string baseUri); [LoggerMessage(4, LogLevel.Debug, $"Displaying {nameof(NotFound)} on request", EventName = "DisplayingNotFoundOnRequest")] - internal static partial void DisplayingNotFound(ILogger logger); + internal static partial void DisplayingNotFound(ILogger logger, string displayedPath); #pragma warning restore CS0618 // Type or member is obsolete } } From 4a88e36e5a35f27fcc118f8407ebce1190ec0859 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 18 Jul 2025 15:56:19 +0200 Subject: [PATCH 3/4] Fix build. --- src/Components/Components/src/Routing/Router.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 609851440f52..b14f59b9c972 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -490,8 +490,8 @@ private static partial class Log [LoggerMessage(3, LogLevel.Debug, "Navigating to non-component URI '{ExternalUri}' in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToExternalUri")] internal static partial void NavigatingToExternalUri(ILogger logger, string externalUri, string path, string baseUri); - [LoggerMessage(4, LogLevel.Debug, $"Displaying {nameof(NotFound)} on request", EventName = "DisplayingNotFoundOnRequest")] - internal static partial void DisplayingNotFound(ILogger logger, string displayedPath); + [LoggerMessage(4, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on request", EventName = "DisplayingNotFoundOnRequest")] + internal static partial void DisplayingNotFound(ILogger logger, string displayedContentPath); #pragma warning restore CS0618 // Type or member is obsolete } } From 3cb8218fba8e16ed6638f8048ce61f106964e2b2 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 18 Jul 2025 16:40:47 +0200 Subject: [PATCH 4/4] Add tests + throw if path does not match any component. --- .../Components/src/Routing/Router.cs | 17 +- .../Components/test/Routing/RouterTest.cs | 192 ++++++++++++++++++ 2 files changed, 202 insertions(+), 7 deletions(-) diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index b14f59b9c972..ecb69fe2cf63 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -409,21 +409,24 @@ private void OnNotFound(object sender, NotFoundEventArgs args) } } - private void RenderComponentByRoute(RenderTreeBuilder builder, string route) + internal void RenderComponentByRoute(RenderTreeBuilder builder, string route) { var componentType = FindComponentTypeByRoute(route); - if (componentType != null) + if (componentType is null) { - builder.OpenComponent(0); - builder.AddAttribute(1, nameof(RouteView.RouteData), - new RouteData(componentType, new Dictionary())); - builder.CloseComponent(); + throw new InvalidOperationException($"No component found for route '{route}'. " + + $"Ensure the route matches a component with a [Route] attribute."); } + + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(RouteView.RouteData), + new RouteData(componentType, new Dictionary())); + builder.CloseComponent(); } [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] - private Type? FindComponentTypeByRoute(string route) + internal Type? FindComponentTypeByRoute(string route) { RefreshRouteTable(); var normalizedRoute = route.StartsWith('/') ? route : $"/{route}"; diff --git a/src/Components/Components/test/Routing/RouterTest.cs b/src/Components/Components/test/Routing/RouterTest.cs index 46bbb04a030f..e3c7f5dafaae 100644 --- a/src/Components/Components/test/Routing/RouterTest.cs +++ b/src/Components/Components/test/Routing/RouterTest.cs @@ -5,6 +5,7 @@ using System.Reflection; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -301,6 +302,192 @@ await renderer.Dispatcher.InvokeAsync(() => Assert.Contains("Use either NotFound or NotFoundPage", exception.Message); } + [Fact] + public async Task OnNotFound_WithNotFoundPageSet_UsesNotFoundPage() + { + // Create a new router instance for this test to control Attach() timing + var services = new ServiceCollection(); + var testNavManager = new TestNavigationManager(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(testNavManager); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + testRenderer.ShouldHandleExceptions = true; + var testRouter = (Router)testRenderer.InstantiateComponent(); + testRouter.AppAssembly = Assembly.GetExecutingAssembly(); + testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}"); + + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly }, + { nameof(Router.NotFoundPage), typeof(NotFoundTestComponent) } + }; + + // Assign the root component ID which will call Attach() + testRenderer.AssignRootComponentId(testRouter); + + // Act + await testRenderer.Dispatcher.InvokeAsync(() => + testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + // Trigger the NavigationManager's OnNotFound event + await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound()); + + // Assert + var lastBatch = testRenderer.Batches.Last(); + var renderedFrame = lastBatch.ReferenceFrames.First(); + Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType); + Assert.Equal(typeof(RouteView), renderedFrame.ComponentType); + + // Verify that the RouteData contains the NotFoundTestComponent + var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First(); + Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType); + var routeData = (RouteData)routeViewFrame.AttributeValue; + Assert.Equal(typeof(NotFoundTestComponent), routeData.PageType); + } + + [Fact] + public async Task OnNotFound_WithArgsPathSet_RendersComponentByRoute() + { + // Create a new router instance for this test to control Attach() timing + var services = new ServiceCollection(); + var testNavManager = new TestNavigationManager(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(testNavManager); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + testRenderer.ShouldHandleExceptions = true; + var testRouter = (Router)testRenderer.InstantiateComponent(); + testRouter.AppAssembly = Assembly.GetExecutingAssembly(); + testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}"); + + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly } + }; + + // Subscribe to OnNotFound event BEFORE router attaches and set args.Path + testNavManager.OnNotFound += (sender, args) => + { + args.Path = "/jan"; // Point to an existing route + }; + + // Assign the root component ID which will call Attach() + testRenderer.AssignRootComponentId(testRouter); + + // Act + await testRenderer.Dispatcher.InvokeAsync(() => + testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + // Trigger the NavigationManager's OnNotFound event + await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound()); + + // Assert + var lastBatch = testRenderer.Batches.Last(); + var renderedFrame = lastBatch.ReferenceFrames.First(); + Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType); + Assert.Equal(typeof(RouteView), renderedFrame.ComponentType); + + // Verify that the RouteData contains the correct component type + var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First(); + Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType); + var routeData = (RouteData)routeViewFrame.AttributeValue; + Assert.Equal(typeof(JanComponent), routeData.PageType); + } + + [Fact] + public async Task OnNotFound_WithBothNotFoundPageAndArgsPath_PreferArgs() + { + // Create a new router instance for this test to control Attach() timing + var services = new ServiceCollection(); + var testNavManager = new TestNavigationManager(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(testNavManager); + services.AddSingleton(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + testRenderer.ShouldHandleExceptions = true; + var testRouter = (Router)testRenderer.InstantiateComponent(); + testRouter.AppAssembly = Assembly.GetExecutingAssembly(); + testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}"); + + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly }, + { nameof(Router.NotFoundPage), typeof(NotFoundTestComponent) } + }; + + // Subscribe to OnNotFound event BEFORE router attaches and sets up its own subscription + testNavManager.OnNotFound += (sender, args) => + { + args.Path = "/jan"; // This should take precedence over NotFoundPage + }; + + // Now assign the root component ID which will call Attach() + testRenderer.AssignRootComponentId(testRouter); + + await testRenderer.Dispatcher.InvokeAsync(() => + testRouter.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + // trigger the NavigationManager's OnNotFound event + await testRenderer.Dispatcher.InvokeAsync(() => testNavManager.TriggerNotFound()); + + // The Router should have rendered using RenderComponentByRoute (args.Path) instead of NotFoundPage + var lastBatch = testRenderer.Batches.Last(); + var renderedFrame = lastBatch.ReferenceFrames.First(); + Assert.Equal(RenderTreeFrameType.Component, renderedFrame.FrameType); + Assert.Equal(typeof(RouteView), renderedFrame.ComponentType); + + // Verify that the RouteData contains the JanComponent (from args.Path), not NotFoundTestComponent + var routeViewFrame = lastBatch.ReferenceFrames.Skip(1).First(); + Assert.Equal(RenderTreeFrameType.Attribute, routeViewFrame.FrameType); + var routeData = (RouteData)routeViewFrame.AttributeValue; + Assert.Equal(typeof(JanComponent), routeData.PageType); + } + + [Fact] + public async Task FindComponentTypeByRoute_WithValidRoute_ReturnsComponentType() + { + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly } + }; + + await _renderer.Dispatcher.InvokeAsync(() => + _router.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + var result = _router.FindComponentTypeByRoute("/jan"); + Assert.Equal(typeof(JanComponent), result); + } + + [Fact] + public async Task RenderComponentByRoute_WithInvalidRoute_ThrowsException() + { + var parameters = new Dictionary + { + { nameof(Router.AppAssembly), typeof(RouterTest).Assembly } + }; + + await _renderer.Dispatcher.InvokeAsync(() => + _router.SetParametersAsync(ParameterView.FromDictionary(parameters))); + + var builder = new RenderTreeBuilder(); + + var exception = Assert.Throws(() => + { + _router.RenderComponentByRoute(builder, "/nonexistent-route"); + }); + Assert.Contains("No component found for route '/nonexistent-route'", exception.Message); + } + internal class TestNavigationManager : NavigationManager { public TestNavigationManager() => @@ -311,6 +498,11 @@ public void NotifyLocationChanged(string uri, bool intercepted, string state = n Uri = uri; NotifyLocationChanged(intercepted); } + + public void TriggerNotFound() + { + base.NotFound(); + } } internal sealed class TestNavigationInterception : INavigationInterception