diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index 1900b91c629b..ff14da1adbb9 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -61,7 +61,7 @@ public event EventHandler OnNotFound // The URI. Always represented an absolute URI. private string? _uri; private bool _isInitialized; - internal string NotFoundPageRoute { get; set; } = string.Empty; + private readonly NotFoundEventArgs _notFoundEventArgs = new(); /// /// Gets or sets the current base URI. The is always represented as an absolute URI in string form with trailing slash. @@ -211,7 +211,7 @@ private void NotFoundCore() } else { - _notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute)); + _notFound.Invoke(this, _notFoundEventArgs); } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index dc07b8afddac..b69167202fee 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -8,8 +8,9 @@ Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHand Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func! onNavigateTo) -> void Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs -Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void -Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! +Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void +Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string? +Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.set -> 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/NotFoundEventArgs.cs b/src/Components/Components/src/Routing/NotFoundEventArgs.cs index e1e81e5cfc82..9263ed62a3ab 100644 --- a/src/Components/Components/src/Routing/NotFoundEventArgs.cs +++ b/src/Components/Components/src/Routing/NotFoundEventArgs.cs @@ -9,16 +9,7 @@ namespace Microsoft.AspNetCore.Components.Routing; public sealed class NotFoundEventArgs : EventArgs { /// - /// Gets the path of NotFoundPage. + /// Gets the path of NotFoundPage. If the path is set, it indicates that the router has handled the rendering of the NotFound contents. /// - public string Path { get; } - - /// - /// Initializes a new instance of . - /// - public NotFoundEventArgs(string url) - { - Path = url; - } - + public string? Path { get; set; } } diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index eedff373f656..ccab45f8e5f2 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -29,6 +29,7 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary string _locationAbsolute; bool _navigationInterceptionEnabled; ILogger _logger; + string _notFoundPageRoute; private string _updateScrollPositionForHashLastLocation; private bool _updateScrollPositionForHash; @@ -159,7 +160,7 @@ public async Task SetParametersAsync(ParameterView parameters) var routeAttribute = (RouteAttribute)routeAttributes[0]; if (routeAttribute.Template != null) { - NavigationManager.NotFoundPageRoute = routeAttribute.Template; + _notFoundPageRoute = routeAttribute.Template; } } @@ -381,10 +382,12 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args) } } - private void OnNotFound(object sender, EventArgs args) + private void OnNotFound(object sender, NotFoundEventArgs args) { - if (_renderHandle.IsInitialized) + if (_renderHandle.IsInitialized && NotFoundPage != null) { + // setting the path signals to the endpoint renderer that router handled rendering + args.Path = _notFoundPageRoute; Log.DisplayingNotFound(_logger); RenderNotFound(); } diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 8e5338f54788..c02595f6e454 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -136,6 +136,11 @@ await _renderer.InitializeStandardComponentServicesAsync( } } + if (_renderer.NotFoundEventArgs != null) + { + _renderer.SetNotFoundWhenResponseNotStarted(); + } + if (!quiesceTask.IsCompleted) { // An incomplete QuiescenceTask indicates there may be streaming rendering updates. @@ -155,6 +160,10 @@ await _renderer.InitializeStandardComponentServicesAsync( if (!quiesceTask.IsCompletedSuccessfully) { await _renderer.SendStreamingUpdatesAsync(context, quiesceTask, bufferWriter); + if (_renderer.NotFoundEventArgs != null) + { + await _renderer.SetNotFoundWhenResponseHasStarted(); + } } else { @@ -168,6 +177,17 @@ await _renderer.InitializeStandardComponentServicesAsync( componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default); } + if (context.Response.StatusCode == StatusCodes.Status404NotFound && + !isReExecuted && + string.IsNullOrEmpty(_renderer.NotFoundEventArgs?.Path)) + { + // Router did not handle the NotFound event, otherwise this would not be empty. + // Don't flush the response if we have an unhandled 404 rendering + // This will allow the StatusCodePages middleware to re-execute the request + context.Response.ContentType = null; + return; + } + // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying // response asynchronously. In the absence of this line, the buffer gets synchronously written to the // response as part of the Dispose which has a perf impact. diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index cdf17e376a00..b1c9621888b0 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -79,27 +79,28 @@ private Task ReturnErrorResponse(string detailedMessage) : Task.CompletedTask; } - internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args) + internal void SetNotFoundWhenResponseNotStarted() { - if (_httpContext.Response.HasStarted || - // POST waits for quiescence -> rendering the NotFoundPage would be queued for the next batch - // but we want to send the signal to the renderer to stop rendering future batches -> use client rendering - HttpMethods.IsPost(_httpContext.Request.Method)) - { - if (string.IsNullOrEmpty(_notFoundUrl)) - { - _notFoundUrl = GetNotFoundUrl(baseUri, args); - } - var defaultBufferSize = 16 * 1024; - await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); - using var bufferWriter = new BufferedTextWriter(writer); - HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl); - await bufferWriter.FlushAsync(); - } - else + _httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + + // 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. + SignalRendererToFinishRendering(); + } + + internal async Task SetNotFoundWhenResponseHasStarted() + { + if (string.IsNullOrEmpty(_notFoundUrl)) { - _httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + var baseUri = $"{_httpContext.Request.Scheme}://{_httpContext.Request.Host}{_httpContext.Request.PathBase}/"; + _notFoundUrl = GetNotFoundUrl(baseUri, NotFoundEventArgs); } + var defaultBufferSize = 16 * 1024; + await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + using var bufferWriter = new BufferedTextWriter(writer); + HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl); + await bufferWriter.FlushAsync(); // 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, @@ -107,9 +108,9 @@ internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs a SignalRendererToFinishRendering(); } - private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args) + private string GetNotFoundUrl(string baseUri, NotFoundEventArgs? args) { - string path = args.Path; + string? path = args?.Path; if (string.IsNullOrEmpty(path)) { var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string; diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index f5d0699e1efe..5a7d38941b23 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -63,6 +63,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log } internal HttpContext? HttpContext => _httpContext; + internal NotFoundEventArgs? NotFoundEventArgs { get; private set; } internal void SetHttpContext(HttpContext httpContext) { @@ -85,10 +86,7 @@ internal async Task InitializeStandardComponentServicesAsync( var navigationManager = httpContext.RequestServices.GetRequiredService(); ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo); - navigationManager?.OnNotFound += (sender, args) => - { - _ = GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args)); - }; + navigationManager?.OnNotFound += (sender, args) => NotFoundEventArgs = args; var authenticationStateProvider = httpContext.RequestServices.GetService(); if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider) diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index 3c27365169cb..d7c2e16e80b2 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -944,7 +944,7 @@ public async Task Renderer_WhenNoNotFoundPathProvided_Throws() httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route var exception = await Assert.ThrowsAsync(async () => - await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs("")) + await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs()) ); string expectedError = "The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started."; @@ -1823,7 +1823,7 @@ protected override void ProcessPendingRender() public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args) { SetHttpContext(httpContext); - await SetNotFoundResponseAsync(httpContext.Request.PathBase, args); + await SetNotFoundWhenResponseHasStarted(); } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index bbeb1ad4d9bb..ca8f502f8472 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -117,6 +117,16 @@ public void BrowserNavigationToNotExistingPathReExecutesTo404(bool streaming) private void AssertReExecutionPageRendered() => Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => Browser.Exists(By.Id("test-info")).Text); + private void AssertBrowserDefaultNotFoundViewRendered() + { + var mainMessage = Browser.FindElement(By.Id("main-message")); + + Browser.True( + () => mainMessage.FindElement(By.CssSelector("p")).Text + .Contains("No webpage was found for the web address:", StringComparison.OrdinalIgnoreCase) + ); + } + private void AssertNotFoundPageRendered() { Browser.Equal("Welcome On Custom Not Found Page", () => Browser.FindElement(By.Id("test-info")).Text); @@ -141,13 +151,45 @@ public void NotFoundSetOnInitialization_ResponseNotStarted_SSR(bool hasReExecuti string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; Navigate(testUrl); - if (hasCustomNotFoundPageSet) + AssertNotFoundRendered_ResponseNotStarted(hasCustomNotFoundPageSet); + AssertUrlNotChanged(testUrl); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnInitialization_AfterAsyncOperation_ResponseNotStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr?doAsync=true&useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + + AssertNotFoundRendered_ResponseNotStarted(hasCustomNotFoundPageSet); + AssertUrlNotChanged(testUrl); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + // our custom router does not support NotFoundPage to simulate the most probable custom router behavior + public void NotFoundSetOnInitialization_ResponseNotStarted_CustomRouter_SSR(bool hasReExecutionMiddleware) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr?useCustomRouter=true"; + Navigate(testUrl); + + if (hasReExecutionMiddleware) { - AssertNotFoundPageRendered(); + AssertReExecutionPageRendered(); } else { - AssertNotFoundFragmentRendered(); + // Apps that don't support re-execution and don't have blazor's router, + // cannot render custom NotFound contents. + // The browser will display default 404 page. + AssertBrowserDefaultNotFoundViewRendered(); } AssertUrlNotChanged(testUrl); } @@ -162,7 +204,7 @@ public void NotFoundSetOnInitialization_ResponseStarted_SSR(bool hasReExecutionM string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; Navigate(testUrl); - AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertNotFoundRendered_ResponseStarted(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); AssertUrlNotChanged(testUrl); } @@ -176,11 +218,47 @@ public void NotFoundSetOnInitialization_ResponseStarted_EnhancedNavigationDisabl string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; Navigate(testUrl); - AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertNotFoundRendered_ResponseStarted(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); AssertUrlChanged(testUrl); } - private void AssertNotFoundRendered_ResponseStarted_Or_POST(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet, string testUrl) + [Theory] + [InlineData(true)] + [InlineData(false)] + // our custom router does not support NotFoundPage to simulate the most probable custom router behavior + public void NotFoundSetOnInitialization_ResponseStarted_CustomRouter_SSR(bool hasReExecutionMiddleware) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr-streaming?useCustomRouter=true"; + Navigate(testUrl); + + if (hasReExecutionMiddleware) + { + AssertReExecutionPageRendered(); + } + else + { + // Apps that don't support re-execution and don't have blazor's router, + // cannot render custom NotFound contents. + // The browser will display default 404 page. + AssertBrowserDefaultNotFoundViewRendered(); + } + AssertUrlNotChanged(testUrl); + } + + private void AssertNotFoundRendered_ResponseNotStarted(bool hasCustomNotFoundPageSet) + { + if (hasCustomNotFoundPageSet) + { + AssertNotFoundPageRendered(); + } + else + { + AssertNotFoundContentNotRendered(); + } + } + + private void AssertNotFoundRendered_ResponseStarted(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet, string testUrl) { if (hasCustomNotFoundPageSet) { @@ -205,11 +283,37 @@ private void AssertNotFoundRendered_ResponseStarted_Or_POST(bool hasReExecutionM public void NotFoundSetOnFormSubmit_ResponseNotStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) { string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; - string testUrl = $"{ServerPathBase}{reexecution}/post-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + string testUrl = $"{ServerPathBase}{reexecution}/post-not-found-ssr?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + Browser.FindElement(By.Id("not-found-form")).FindElement(By.TagName("button")).Click(); + + AssertNotFoundRendered_ResponseNotStarted(hasCustomNotFoundPageSet); + AssertUrlNotChanged(testUrl); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnFormSubmit_AfterAsyncOperation_ResponseNotStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/post-not-found-ssr?doAsync=true&useCustomNotFoundPage={hasCustomNotFoundPageSet}"; Navigate(testUrl); Browser.FindElement(By.Id("not-found-form")).FindElement(By.TagName("button")).Click(); - AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertNotFoundRendered_ResponseNotStarted(hasCustomNotFoundPageSet); + AssertUrlNotChanged(testUrl); + } + + [Fact] + public void NotFoundSetOnFormSubmit_ResponseNotStarted_CustomRouter_SSR() + { + string testUrl = $"{ServerPathBase}/reexecution/post-not-found-ssr?useCustomRouter=true"; + Navigate(testUrl); + + AssertReExecutionPageRendered(); AssertUrlNotChanged(testUrl); } @@ -225,12 +329,19 @@ public void NotFoundSetOnFormSubmit_ResponseStarted_SSR(bool hasReExecutionMiddl Navigate(testUrl); Browser.FindElement(By.Id("not-found-form")).FindElement(By.TagName("button")).Click(); - AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertNotFoundRendered_ResponseStarted(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); AssertUrlNotChanged(testUrl); } - private void AssertNotFoundFragmentRendered() => - Browser.Equal("There's nothing here", () => Browser.FindElement(By.Id("not-found-fragment")).Text); + [Fact] + public void NotFoundSetOnFormSubmit_ResponseStarted_CustomRouter_SSR() + { + string testUrl = $"{ServerPathBase}/reexecution/post-not-found-ssr-streaming?useCustomRouter=true"; + Navigate(testUrl); + + AssertReExecutionPageRendered(); + AssertUrlNotChanged(testUrl); + } private void AssertNotFoundContentNotRendered() => Browser.Equal("Any content", () => Browser.FindElement(By.Id("test-info")).Text); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index 2219da3955d2..457fcb518f4c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -1,12 +1,17 @@ @using Components.TestServer.RazorComponents.Pages.Forms @using Components.WasmMinimal.Pages.NotFound @using TestContentPackage.NotFound +@using Components.TestServer.RazorComponents @code { [Parameter] [SupplyParameterFromQuery(Name = "useCustomNotFoundPage")] public string? UseCustomNotFoundPage { get; set; } + [Parameter] + [SupplyParameterFromQuery(Name = "useCustomRouter")] + public string? UseCustomRouter { get; set; } + private Type? NotFoundPageType { get; set; } protected override void OnParametersSet() @@ -30,13 +35,25 @@ - - - - - -

There's nothing here

-
+ @if(string.Equals(UseCustomRouter, "true", StringComparison.OrdinalIgnoreCase)) + { + + + + + + + } + else + { + + + + + +

There's nothing here

+
+ }