Skip to content

[release/10.0-preview7] Support all subscribers to OnNotFound event #62835

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteView>(0);
builder.AddAttribute(1, nameof(RouteView.RouteData),
new RouteData(componentType, new Dictionary<string, object>()));
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()
Expand Down Expand Up @@ -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
}
}
192 changes: 192 additions & 0 deletions src/Components/Components/test/Routing/RouterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ILoggerFactory>(NullLoggerFactory.Instance);
services.AddSingleton<NavigationManager>(testNavManager);
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
var serviceProvider = services.BuildServiceProvider();

var testRenderer = new TestRenderer(serviceProvider);
testRenderer.ShouldHandleExceptions = true;
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");

var parameters = new Dictionary<string, object>
{
{ 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<ILoggerFactory>(NullLoggerFactory.Instance);
services.AddSingleton<NavigationManager>(testNavManager);
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
var serviceProvider = services.BuildServiceProvider();

var testRenderer = new TestRenderer(serviceProvider);
testRenderer.ShouldHandleExceptions = true;
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");

var parameters = new Dictionary<string, object>
{
{ 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<ILoggerFactory>(NullLoggerFactory.Instance);
services.AddSingleton<NavigationManager>(testNavManager);
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
var serviceProvider = services.BuildServiceProvider();

var testRenderer = new TestRenderer(serviceProvider);
testRenderer.ShouldHandleExceptions = true;
var testRouter = (Router)testRenderer.InstantiateComponent<Router>();
testRouter.AppAssembly = Assembly.GetExecutingAssembly();
testRouter.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");

var parameters = new Dictionary<string, object>
{
{ 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<string, object>
{
{ 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<string, object>
{
{ nameof(Router.AppAssembly), typeof(RouterTest).Assembly }
};

await _renderer.Dispatcher.InvokeAsync(() =>
_router.SetParametersAsync(ParameterView.FromDictionary(parameters)));

var builder = new RenderTreeBuilder();

var exception = Assert.Throws<InvalidOperationException>(() =>
{
_router.RenderComponentByRoute(builder, "/nonexistent-route");
});
Assert.Contains("No component found for route '/nonexistent-route'", exception.Message);
}

internal class TestNavigationManager : NavigationManager
{
public TestNavigationManager() =>
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
{
Expand All @@ -24,6 +57,18 @@
{
NotFoundPageType = null;
}
if (AppSetsEventArgsPath && _navigationManager is not null)
{
_navigationManager.OnNotFound += OnNotFoundEvent;
}
}

public void Dispose()
{
if (AppSetsEventArgsPath)
{
_navigationManager.OnNotFound -= OnNotFoundEvent;
}
}
}

Expand Down
Loading