diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index fab5c69e9635..1c8c8e01563f 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -35,6 +35,25 @@ public event EventHandler LocationChanged private CancellationTokenSource? _locationChangingCts; + /// + /// An event that fires when the page is not found. + /// + public event EventHandler NotFoundEvent + { + add + { + AssertInitialized(); + _notFound += value; + } + remove + { + AssertInitialized(); + _notFound -= value; + } + } + + private EventHandler? _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; @@ -177,6 +196,16 @@ protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)] public virtual void Refresh(bool forceReload = false) => NavigateTo(Uri, forceLoad: true, replace: true); + /// + /// Handles setting the NotFound state. + /// + public virtual void NotFound() => NotFoundCore(); + + private void NotFoundCore() + { + _notFound?.Invoke(this, new EventArgs()); + } + /// /// Called to initialize BaseURI and current URI before these values are used for the first time. /// Override and call this method to dynamically calculate these values. diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index a29a6819ac5a..441f46ffe210 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Components.NavigationManager.NotFoundEvent -> System.EventHandler! +virtual Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 072418b1f688..f27226d492a9 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -105,6 +105,7 @@ public void Attach(RenderHandle renderHandle) _baseUri = NavigationManager.BaseUri; _locationAbsolute = NavigationManager.Uri; NavigationManager.LocationChanged += OnLocationChanged; + NavigationManager.NotFoundEvent += OnNotFound; RoutingStateProvider = ServiceProvider.GetService(); if (HotReloadManager.Default.MetadataUpdateSupported) @@ -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; @@ -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) @@ -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); } } diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 7a76a43098d4..7638eda6163b 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -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, diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index cd47eb63590f..2b8455741f52 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -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) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index a28fe996e554..428e84d3ead5 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -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 @@ -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?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request)); + var navigationManager = httpContext.RequestServices.GetRequiredService(); + ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request)); + + if (navigationManager != null) + { + navigationManager.NotFoundEvent += SetNotFoundResponse; + } var authenticationStateProvider = httpContext.RequestServices.GetService(); if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider) @@ -170,6 +176,23 @@ 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; @@ -177,7 +200,7 @@ 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 diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index a2a148945ee1..9fcc668d94c1 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -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); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index b0f887da31df..0b30003f4b70 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -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; diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index c35ca3680d8f..2f1f70aa7511 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -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; @@ -17,6 +20,40 @@ public class GlobalInteractivityTest( ITestOutputHelper output) : ServerTestBase>>(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 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() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor index f4297a4360c6..e923f23ee88a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor @@ -23,4 +23,13 @@
  • Complex segments
  • +
  • + Not found page for Static Server Rendering +
  • +
  • + Not found page for Interactive WebAssembly rendering +
  • +
  • + Not found page for Interactive Server rendering +
  • diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveServer.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveServer.razor new file mode 100644 index 000000000000..1212703d4b4f --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveServer.razor @@ -0,0 +1,17 @@ +@page "/render-not-found-server" +@using Microsoft.AspNetCore.Components; +@using Microsoft.AspNetCore.Components.Web; +@inject NavigationManager NavigationManager + +

    Any content

    +@if(RendererInfo.IsInteractive) +{ + +} + +@code{ + private void TriggerNotFound() + { + NavigationManager.NotFound(); + } +} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveWebassembly.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveWebassembly.razor new file mode 100644 index 000000000000..91f31dec78a4 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundInteractiveWebassembly.razor @@ -0,0 +1,17 @@ +@page "/render-not-found-webassembly" +@using Microsoft.AspNetCore.Components; +@using Microsoft.AspNetCore.Components.Web; +@inject NavigationManager NavigationManager + +

    Any content

    +@if(RendererInfo.IsInteractive) +{ + +} + +@code{ + private void TriggerNotFound() + { + NavigationManager.NotFound(); + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundSSR.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundSSR.razor new file mode 100644 index 000000000000..a4d3fbe8f7aa --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFoundSSR.razor @@ -0,0 +1,11 @@ +@page "/render-not-found-ssr" +@inject NavigationManager NavigationManager + +

    Any content

    + +@code{ + protected override void OnInitialized() + { + NavigationManager.NotFound(); + } +} \ No newline at end of file diff --git a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs index 18e7efa00274..cefb5d70a82e 100644 --- a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs @@ -77,9 +77,6 @@ private static TagHelperOutput GetTagHelperOutput() private ViewContext GetViewContext() { - var navManager = new Mock(); - navManager.As(); - var httpContext = new DefaultHttpContext { RequestServices = new ServiceCollection() @@ -89,7 +86,7 @@ private ViewContext GetViewContext() x => x.CreateProtector(It.IsAny()) == Mock.Of())) .AddLogging() .AddScoped() - .AddScoped(_ => navManager.Object) + .AddScoped() .AddScoped() .BuildServiceProvider(), }; @@ -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) + { + } + } }