diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index fc18c528a95c..ecb69fe2cf63 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -391,13 +391,55 @@ 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); - RenderNotFound(); + 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)); + } + else + { + // Having the path set signals to the endpoint renderer that router handled rendering. + args.Path = _notFoundPageRoute; + RenderNotFound(); + } + Log.DisplayingNotFound(_logger, args.Path); + } + } + + internal void RenderComponentByRoute(RenderTreeBuilder builder, string route) + { + var componentType = FindComponentTypeByRoute(route); + + if (componentType is null) + { + 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)] + internal 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() @@ -451,8 +493,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); + [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 } } 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 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; + } } }