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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy