Skip to content

Add NotFound method in NavigationManager for interactive and static rendering #60752

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 23 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
29 changes: 29 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ public event EventHandler<LocationChangedEventArgs> LocationChanged

private CancellationTokenSource? _locationChangingCts;

/// <summary>
/// An event that fires when the page is not found.
/// </summary>
public event EventHandler<EventArgs> NotFoundEvent
{
add
{
AssertInitialized();
_notFound += value;
}
remove
{
AssertInitialized();
_notFound -= value;
}
}

private EventHandler<EventArgs>? _notFound;

// For the baseUri it's worth storing as a System.Uri so we can do operations
// on that type. System.Uri gives us access to the original string anyway.
private Uri? _baseUri;
Expand Down Expand Up @@ -177,6 +196,16 @@ protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)]
public virtual void Refresh(bool forceReload = false)
=> NavigateTo(Uri, forceLoad: true, replace: true);

/// <summary>
/// Handles setting the NotFound state.
/// </summary>
public virtual void NotFound() => NotFoundCore();

private void NotFoundCore()
{
_notFound?.Invoke(this, new EventArgs());
}

/// <summary>
/// Called to initialize BaseURI and current URI before these values are used for the first time.
/// Override <see cref="EnsureInitialized" /> and call this method to dynamically calculate these values.
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationManager.NotFoundEvent -> System.EventHandler<System.EventArgs!>!
virtual Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions
Expand Down
14 changes: 14 additions & 0 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public void Attach(RenderHandle renderHandle)
_baseUri = NavigationManager.BaseUri;
_locationAbsolute = NavigationManager.Uri;
NavigationManager.LocationChanged += OnLocationChanged;
NavigationManager.NotFoundEvent += OnNotFound;
RoutingStateProvider = ServiceProvider.GetService<IRoutingStateProvider>();

if (HotReloadManager.Default.MetadataUpdateSupported)
Expand Down Expand Up @@ -146,6 +147,7 @@ public async Task SetParametersAsync(ParameterView parameters)
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
NavigationManager.NotFoundEvent -= OnNotFound;
if (HotReloadManager.Default.MetadataUpdateSupported)
{
HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
Expand Down Expand Up @@ -320,6 +322,15 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
}
}

private void OnNotFound(object sender, EventArgs args)
{
if (_renderHandle.IsInitialized)
{
Log.DisplayingNotFound(_logger);
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
}
}

async Task IHandleAfterRender.OnAfterRenderAsync()
{
if (!_navigationInterceptionEnabled)
Expand All @@ -345,5 +356,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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private async Task RenderComponentCore(HttpContext context)
return Task.CompletedTask;
});

await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
await _renderer.InitializeStandardComponentServicesAsync(
context,
componentType: pageComponent,
handler: result.HandlerName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ private Task ReturnErrorResponse(string detailedMessage)
: Task.CompletedTask;
}

private void SetNotFoundResponse(object? sender, EventArgs args)
{
if (_httpContext.Response.HasStarted)
{
throw new InvalidOperationException("Cannot set a NotFound response after the response has already started.");
}
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
SignalRendererToFinishRendering();
}

private void UpdateNamedSubmitEvents(in RenderBatch renderBatch)
{
if (renderBatch.NamedEventChanges is { } changes)
Expand Down
31 changes: 27 additions & 4 deletions src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
private Task? _servicesInitializedTask;
private HttpContext _httpContext = default!; // Always set at the start of an inbound call
private ResourceAssetCollection? _resourceCollection;
private bool _rendererIsStopped;

// The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e.,
// when everything (regardless of streaming SSR) is fully complete. In this subclass we also track
Expand Down Expand Up @@ -71,14 +72,19 @@ private void SetHttpContext(HttpContext httpContext)
}
}

internal static async Task InitializeStandardComponentServicesAsync(
internal async Task InitializeStandardComponentServicesAsync(
HttpContext httpContext,
[DynamicallyAccessedMembers(Component)] Type? componentType = null,
string? handler = null,
IFormCollection? form = null)
{
var navigationManager = (IHostEnvironmentNavigationManager)httpContext.RequestServices.GetRequiredService<NavigationManager>();
navigationManager?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request));
var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>();
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request));

if (navigationManager != null)
{
navigationManager.NotFoundEvent += SetNotFoundResponse;
}

var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider)
Expand Down Expand Up @@ -170,14 +176,31 @@ protected override void AddPendingTask(ComponentState? componentState, Task task
base.AddPendingTask(componentState, task);
}

private void SignalRendererToFinishRendering()
{
_rendererIsStopped = true;
}

protected override void ProcessPendingRender()
{
if (_rendererIsStopped)
{
// When the application triggers a NotFound event, we continue rendering the current batch.
// However, after completing this batch, we do not want to process any further UI updates,
// as we are going to return a 404 status and discard the UI updates generated so far.
return;
}
base.ProcessPendingRender();
}

// For tests only
internal Task? NonStreamingPendingTasksCompletion;

protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
{
UpdateNamedSubmitEvents(in renderBatch);

if (_streamingUpdatesWriter is { } writer)
if (_streamingUpdatesWriter is { } writer && !_rendererIsStopped)
{
// Important: SendBatchAsStreamingUpdate *must* be invoked synchronously
// before any 'await' in this method. That's enforced by the compiler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ public static void RequestingNavigation(ILogger logger, string uri, NavigationOp
[LoggerMessage(5, LogLevel.Error, "Failed to refresh", EventName = "RefreshFailed")]
public static partial void RefreshFailed(ILogger logger, Exception exception);

[LoggerMessage(1, LogLevel.Debug, "Requesting not found", EventName = "RequestingNotFound")]
public static partial void RequestingNotFound(ILogger logger);

[LoggerMessage(6, LogLevel.Debug, "Navigation completed when changing the location to {Uri}", EventName = "NavigationCompleted")]
public static partial void NavigationCompleted(ILogger logger, string uri);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
Expand Down
37 changes: 37 additions & 0 deletions src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net;
using System.Net.Http;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
Expand All @@ -17,6 +20,40 @@ public class GlobalInteractivityTest(
ITestOutputHelper output)
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<GlobalInteractivityApp>>>(browserFixture, serverFixture, output)
{

[Theory]
[InlineData("server", true)]
[InlineData("webassembly", true)]
[InlineData("ssr", false)]
public void CanRenderNotFoundInteractive(string renderingMode, bool isInteractive)
{
Navigate($"/subdir/render-not-found-{renderingMode}");

if (isInteractive)
{
var buttonId = "trigger-not-found";
Browser.WaitForElementToBeVisible(By.Id(buttonId));
Browser.Exists(By.Id(buttonId)).Click();
}

var bodyText = Browser.FindElement(By.TagName("body")).Text;
Assert.Contains("There's nothing here", bodyText);
}

[Fact]
public async Task CanSetNotFoundStatus()
{
var statusCode = await GetStatusCodeAsync($"/subdir/render-not-found-ssr");
Assert.Equal(HttpStatusCode.NotFound, statusCode);
}

private async Task<HttpStatusCode> GetStatusCodeAsync(string relativeUrl)
{
using var client = new HttpClient() { BaseAddress = _serverFixture.RootUri };
var response = await client.GetAsync(relativeUrl, HttpCompletionOption.ResponseHeadersRead);
return response.StatusCode;
}

[Fact]
public void CanFindStaticallyRenderedPageAfterClickingBrowserBackButtonOnDynamicallyRenderedPage()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@
<li>
<a href="routing/complex-segment(value)">Complex segments</a>
</li>
<li>
<a href="routing/not-found-ssr">Not found page for Static Server Rendering</a>
</li>
<li>
<a href="routing/not-found-webassembly">Not found page for Interactive WebAssembly rendering</a>
</li>
<li>
<a href="routing/not-found-server">Not found page for Interactive Server rendering</a>
</li>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@page "/render-not-found-server"
@using Microsoft.AspNetCore.Components;
@using Microsoft.AspNetCore.Components.Web;
@inject NavigationManager NavigationManager

<p id="test-info">Any content</p>
@if(RendererInfo.IsInteractive)
{
<button id="trigger-not-found" @onclick="@TriggerNotFound">Trigger not found button</button>
}

@code{
private void TriggerNotFound()
{
NavigationManager.NotFound();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@page "/render-not-found-webassembly"
@using Microsoft.AspNetCore.Components;
@using Microsoft.AspNetCore.Components.Web;
@inject NavigationManager NavigationManager

<p id="test-info">Any content</p>
@if(RendererInfo.IsInteractive)
{
<button id="trigger-not-found" @onclick="@TriggerNotFound">Trigger not found button</button>
}

@code{
private void TriggerNotFound()
{
NavigationManager.NotFound();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@page "/render-not-found-ssr"
@inject NavigationManager NavigationManager

<p id="test-info">Any content</p>

@code{
protected override void OnInitialized()
{
NavigationManager.NotFound();
}
}
17 changes: 13 additions & 4 deletions src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,6 @@ private static TagHelperOutput GetTagHelperOutput()

private ViewContext GetViewContext()
{
var navManager = new Mock<NavigationManager>();
navManager.As<IHostEnvironmentNavigationManager>();

var httpContext = new DefaultHttpContext
{
RequestServices = new ServiceCollection()
Expand All @@ -89,7 +86,7 @@ private ViewContext GetViewContext()
x => x.CreateProtector(It.IsAny<string>()) == Mock.Of<IDataProtector>()))
.AddLogging()
.AddScoped<ComponentStatePersistenceManager>()
.AddScoped(_ => navManager.Object)
.AddScoped<NavigationManager, MockNavigationManager>()
.AddScoped<HttpContextFormDataProvider>()
.BuildServiceProvider(),
};
Expand All @@ -104,4 +101,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddContent(0, "Hello from the component");
}
}

class MockNavigationManager : NavigationManager, IHostEnvironmentNavigationManager
{
public MockNavigationManager()
{
Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
}

void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri)
{
}
}
}
Loading