diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index 25d6da65b94e..1900b91c629b 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -54,8 +54,6 @@ public event EventHandler<NotFoundEventArgs> OnNotFound private EventHandler<NotFoundEventArgs>? _notFound; - private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs(); - // 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; @@ -63,6 +61,7 @@ public event EventHandler<NotFoundEventArgs> OnNotFound // The URI. Always represented an absolute URI. private string? _uri; private bool _isInitialized; + internal string NotFoundPageRoute { get; set; } = string.Empty; /// <summary> /// Gets or sets the current base URI. The <see cref="BaseUri" /> is always represented as an absolute URI in string form with trailing slash. @@ -212,7 +211,7 @@ private void NotFoundCore() } else { - _notFound.Invoke(this, _notFoundEventArgs); + _notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute)); } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 5eb52d2c330e..07e51aca6bd3 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -6,7 +6,8 @@ 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<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs -Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void +Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void +Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! 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 diff --git a/src/Components/Components/src/Routing/NotFoundEventArgs.cs b/src/Components/Components/src/Routing/NotFoundEventArgs.cs index 637bfd442b70..e1e81e5cfc82 100644 --- a/src/Components/Components/src/Routing/NotFoundEventArgs.cs +++ b/src/Components/Components/src/Routing/NotFoundEventArgs.cs @@ -8,9 +8,17 @@ namespace Microsoft.AspNetCore.Components.Routing; /// </summary> public sealed class NotFoundEventArgs : EventArgs { + /// <summary> + /// Gets the path of NotFoundPage. + /// </summary> + public string Path { get; } + /// <summary> /// Initializes a new instance of <see cref="NotFoundEventArgs" />. /// </summary> - public NotFoundEventArgs() - { } + public NotFoundEventArgs(string url) + { + Path = url; + } + } diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 7a73ead53ea9..eedff373f656 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -155,6 +155,12 @@ public async Task SetParametersAsync(ParameterView parameters) throw new InvalidOperationException($"The type {NotFoundPage.FullName} " + $"does not have a {typeof(RouteAttribute).FullName} applied to it."); } + + var routeAttribute = (RouteAttribute)routeAttributes[0]; + if (routeAttribute.Template != null) + { + NavigationManager.NotFoundPageRoute = routeAttribute.Template; + } } if (!_onNavigateCalled) diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index a52f6e274410..8e5338f54788 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -111,13 +111,6 @@ await _renderer.InitializeStandardComponentServicesAsync( ParameterView.Empty, waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted); - bool avoidStartingResponse = hasStatusCodePage && !isReExecuted && context.Response.StatusCode == StatusCodes.Status404NotFound; - if (avoidStartingResponse) - { - // the request is going to be re-executed, we should avoid writing to the response - return; - } - Task quiesceTask; if (!result.IsPost) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index 6af020fc847a..cdf17e376a00 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -1,9 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Endpoints.Rendering; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; @@ -77,21 +79,26 @@ private Task ReturnErrorResponse(string detailedMessage) : Task.CompletedTask; } - private async Task SetNotFoundResponseAsync(string baseUri) + internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args) { - if (_httpContext.Response.HasStarted) + 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<byte>.Shared, ArrayPool<char>.Shared); using var bufferWriter = new BufferedTextWriter(writer); - var notFoundUri = $"{baseUri}not-found"; - HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, notFoundUri); + HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl); await bufferWriter.FlushAsync(); } else { _httpContext.Response.StatusCode = StatusCodes.Status404NotFound; - _httpContext.Response.ContentType = null; } // When the application triggers a NotFound event, we continue rendering the current batch. @@ -100,6 +107,22 @@ private async Task SetNotFoundResponseAsync(string baseUri) SignalRendererToFinishRendering(); } + private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args) + { + string path = args.Path; + if (string.IsNullOrEmpty(path)) + { + var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string; + if (string.IsNullOrEmpty(pathFormat)) + { + throw new InvalidOperationException("The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started."); + } + + path = pathFormat; + } + return $"{baseUri}{path.TrimStart('/')}"; + } + private async Task OnNavigateTo(string uri) { if (_httpContext.Response.HasStarted) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index c17f7cd53555..926aa42fe8d8 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -226,16 +226,27 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext, writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>"); } + private static void HandleNotFoundAfterResponseStarted(TextWriter writer, HttpContext httpContext, string notFoundUrl) + { + writer.Write("<blazor-ssr><template type=\"not-found\""); + WriteResponseTemplate(writer, httpContext, notFoundUrl, useEnhancedNav: true); + } + private static void HandleNavigationAfterResponseStarted(TextWriter writer, HttpContext httpContext, string destinationUrl) { writer.Write("<blazor-ssr><template type=\"redirection\""); + bool useEnhancedNav = IsProgressivelyEnhancedNavigation(httpContext.Request); + WriteResponseTemplate(writer, httpContext, destinationUrl, useEnhancedNav); + } - if (string.Equals(httpContext.Request.Method, "POST", StringComparison.OrdinalIgnoreCase)) + private static void WriteResponseTemplate(TextWriter writer, HttpContext httpContext, string destinationUrl, bool useEnhancedNav) + { + if (HttpMethods.IsPost(httpContext.Request.Method)) { writer.Write(" from=\"form-post\""); } - if (IsProgressivelyEnhancedNavigation(httpContext.Request)) + if (useEnhancedNav) { writer.Write(" enhanced=\"true\""); } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 33e52fa0ed57..f5d0699e1efe 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -52,6 +52,8 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer // wait for the non-streaming tasks (these ones), then start streaming until full quiescence. private readonly List<Task> _nonStreamingPendingTasks = new(); + private string _notFoundUrl = string.Empty; + public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory) { @@ -62,7 +64,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log internal HttpContext? HttpContext => _httpContext; - private void SetHttpContext(HttpContext httpContext) + internal void SetHttpContext(HttpContext httpContext) { if (_httpContext is null) { @@ -83,10 +85,10 @@ internal async Task InitializeStandardComponentServicesAsync( var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>(); ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo); - if (navigationManager != null) + navigationManager?.OnNotFound += (sender, args) => { - navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri); - } + _ = GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args)); + }; var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>(); if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider) diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index ac787b76bfe5..23b4b87d0b2f 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -1,9 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Endpoints.Forms; using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents; using Microsoft.AspNetCore.Components.Forms; @@ -12,6 +14,7 @@ using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.DataProtection; @@ -934,6 +937,26 @@ await renderer.PrerenderComponentAsync( Assert.Equal("http://localhost/redirect", ctx.Response.Headers.Location); } + [Fact] + public async Task Renderer_WhenNoNotFoundPathProvided_Throws() + { + // Arrange + var httpContext = GetHttpContext(); + var responseMock = new Mock<IHttpResponseFeature>(); + responseMock.Setup(r => r.HasStarted).Returns(true); + responseMock.Setup(r => r.Headers).Returns(new HeaderDictionary()); + httpContext.Features.Set(responseMock.Object); + var renderer = GetEndpointHtmlRenderer(); + httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route + + var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => + 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."; + + Assert.Equal(expectedError, exception.Message); + } + [Fact] public async Task CanRender_AsyncComponent() { @@ -1802,6 +1825,12 @@ protected override void ProcessPendingRender() _rendererIsStopped = true; base.SignalRendererToFinishRendering(); } + + public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args) + { + SetHttpContext(httpContext); + await SetNotFoundResponseAsync(httpContext.Request.PathBase, args); + } } private HttpContext GetHttpContext(HttpContext context = null) diff --git a/src/Components/Web.JS/src/Rendering/StreamingRendering.ts b/src/Components/Web.JS/src/Rendering/StreamingRendering.ts index 06c650c2f99c..7ac02952068e 100644 --- a/src/Components/Web.JS/src/Rendering/StreamingRendering.ts +++ b/src/Components/Web.JS/src/Rendering/StreamingRendering.ts @@ -45,35 +45,16 @@ class BlazorStreamingUpdate extends HTMLElement { insertStreamingContentIntoDocument(componentId, node.content); } } else { + const isEnhancedNav = node.getAttribute('enhanced') === 'true'; switch (node.getAttribute('type')) { case 'redirection': - // We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense - // if the async delay was very short, as the user would not perceive having been on the intermediate page. - const destinationUrl = toAbsoluteUri(node.content.textContent!); - const isFormPost = node.getAttribute('from') === 'form-post'; - const isEnhancedNav = node.getAttribute('enhanced') === 'true'; - if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) { - // At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or - // whether it's even going to the same URL we're currently on. So we don't know how to update the history. - // Defer that until the redirection is resolved by performEnhancedPageLoad. - const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get'; - const fetchOptions = undefined; - performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod); - } else { - if (isFormPost) { - // The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location. - // WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL. - // We could change the server-side logic to return URLs in plaintext if they match the current request URL already, - // but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the - // case for non-streaming responses. - if (destinationUrl !== location.href) { - location.assign(destinationUrl); - } - } else { - // The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location. - location.replace(destinationUrl); - } - } + redirect(node, true, isEnhancedNav); + break; + case 'not-found': + // not-found template has enhanced nav set to true by default, + // check for the options to avoid overriding user settings + const useEnhancedNav = isEnhancedNav && enableDomPreservation; + redirect(node, false, useEnhancedNav); break; case 'error': // This is kind of brutal but matches what happens without progressive enhancement @@ -86,6 +67,35 @@ class BlazorStreamingUpdate extends HTMLElement { } } +function redirect(node: HTMLTemplateElement, changeUrl: boolean, isEnhancedNav: boolean): void { + // We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense + // if the async delay was very short, as the user would not perceive having been on the intermediate page. + const destinationUrl = toAbsoluteUri(node.content.textContent!); + const isFormPost = node.getAttribute('from') === 'form-post'; + if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) { + // At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or + // whether it's even going to the same URL we're currently on. So we don't know how to update the history. + // Defer that until the redirection is resolved by performEnhancedPageLoad. + const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get'; + const fetchOptions = undefined; + performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod, changeUrl); + } else { + if (isFormPost) { + // The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location. + // WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL. + // We could change the server-side logic to return URLs in plaintext if they match the current request URL already, + // but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the + // case for non-streaming responses. + if (destinationUrl !== location.href) { + location.assign(destinationUrl); + } + } else { + // The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location. + location.replace(destinationUrl); + } + } +} + function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void { const markers = findStreamingMarkers(componentIdAsString); if (markers) { diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index 9965f2b727e2..e029c77178dd 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -192,7 +192,7 @@ function onDocumentSubmit(event: SubmitEvent) { } } -export async function performEnhancedPageLoad(internalDestinationHref: string, interceptedLink: boolean, fetchOptions?: RequestInit, treatAsRedirectionFromMethod?: 'get' | 'post') { +export async function performEnhancedPageLoad(internalDestinationHref: string, interceptedLink: boolean, fetchOptions?: RequestInit, treatAsRedirectionFromMethod?: 'get' | 'post', changeUrl: boolean = true) { performingEnhancedPageLoad = true; // First, stop any preceding enhanced page load @@ -257,7 +257,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i // For 301/302/etc redirections to internal URLs, the browser will already have followed the chain of redirections // to the end, and given us the final content. We do still need to update the current URL to match the final location, // then let the rest of enhanced nav logic run to patch the new content into the DOM. - if (response.redirected || treatAsRedirectionFromMethod) { + if (changeUrl && (response.redirected || treatAsRedirectionFromMethod)) { const treatAsGet = treatAsRedirectionFromMethod ? (treatAsRedirectionFromMethod === 'get') : isGetRequest; if (treatAsGet) { // For gets, the intermediate (redirecting) URL is already in the address bar, so we have to use 'replace' @@ -274,12 +274,12 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i // For enhanced nav redirecting to an external URL, we'll get a special Blazor-specific redirection command const externalRedirectionUrl = response.headers.get('blazor-enhanced-nav-redirect-location'); - if (externalRedirectionUrl) { + if (changeUrl && externalRedirectionUrl) { location.replace(externalRedirectionUrl); return; } - if (!response.redirected && !isGetRequest && isSuccessResponse) { + if (changeUrl && !response.redirected && !isGetRequest && isSuccessResponse) { // If this is the result of a form post that didn't trigger a redirection. if (!isForSamePath(response.url, currentContentUrl)) { // In this case we don't want to push the currentContentUrl to the history stack because we don't know if this is a location @@ -296,7 +296,9 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i } // Set the currentContentUrl to the location of the last completed navigation. - currentContentUrl = response.url; + if (changeUrl) { + currentContentUrl = response.url; + } const responseContentType = response.headers.get('content-type'); if (responseContentType?.startsWith('text/html') && initialContent) { diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 35f7140f9170..e5a438283f84 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -80,13 +80,6 @@ public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowBy Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); } - [Fact] - public void CanRenderNotFoundPageAfterStreamingStarted() - { - Navigate($"{ServerPathBase}/streaming-set-not-found"); - Browser.Equal("Default Not Found Page", () => Browser.Title); - } - [Theory] [InlineData(true)] [InlineData(false)] @@ -94,7 +87,7 @@ public void ProgrammaticNavigationToNotExistingPathReExecutesTo404(bool streamin { string streamingPath = streaming ? "-streaming" : ""; Navigate($"{ServerPathBase}/reexecution/redirection-not-found-ssr{streamingPath}?navigate-programmatically=true"); - Assert404ReExecuted(); + AssertReExecutionPageRendered(); } [Theory] @@ -105,7 +98,7 @@ public void LinkNavigationToNotExistingPathReExecutesTo404(bool streaming) string streamingPath = streaming ? "-streaming" : ""; Navigate($"{ServerPathBase}/reexecution/redirection-not-found-ssr{streamingPath}"); Browser.Click(By.Id("link-to-not-existing-page")); - Assert404ReExecuted(); + AssertReExecutionPageRendered(); } [Theory] @@ -118,45 +111,134 @@ public void BrowserNavigationToNotExistingPathReExecutesTo404(bool streaming) // will not be activated, see configuration in Startup string streamingPath = streaming ? "-streaming" : ""; Navigate($"{ServerPathBase}/reexecution/not-existing-page-ssr{streamingPath}"); - Assert404ReExecuted(); + AssertReExecutionPageRendered(); } - private void Assert404ReExecuted() => + private void AssertReExecutionPageRendered() => Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => 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); + // custom page should have a custom layout + Browser.Equal("About", () => Browser.FindElement(By.Id("about-link")).Text); + } + + private void AssertUrlNotChanged(string expectedUrl) => + Browser.True(() => Browser.Url.Contains(expectedUrl), $"Expected URL to contain '{expectedUrl}', but found '{Browser.Url}'"); + + private void AssertUrlChanged(string urlPart) => + Browser.False(() => Browser.Url.Contains(urlPart), $"Expected URL not to contain '{urlPart}', but found '{Browser.Url}'"); + [Theory] - [InlineData(true)] - [InlineData(false)] - public void CanRenderNotFoundPageNoStreaming(bool useCustomNotFoundPage) + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnInitialization_ResponseNotStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) { - string query = useCustomNotFoundPage ? "&useCustomNotFoundPage=true" : ""; - Navigate($"{ServerPathBase}/set-not-found?shouldSet=true{query}"); + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); - if (useCustomNotFoundPage) + if (hasCustomNotFoundPageSet) { - var infoText = Browser.FindElement(By.Id("test-info")).Text; - Assert.Contains("Welcome On Custom Not Found Page", infoText); - // custom page should have a custom layout - var aboutLink = Browser.FindElement(By.Id("about-link")).Text; - Assert.Contains("About", aboutLink); + AssertNotFoundPageRendered(); } else { - var bodyText = Browser.FindElement(By.TagName("body")).Text; - Assert.Contains("There's nothing here", bodyText); + AssertNotFoundFragmentRendered(); } + AssertUrlNotChanged(testUrl); } [Theory] - [InlineData(true)] - [InlineData(false)] - public void CanRenderNotFoundPageWithStreaming(bool useCustomNotFoundPage) + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnInitialization_ResponseStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertUrlNotChanged(testUrl); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + public void NotFoundSetOnInitialization_ResponseStarted_EnhancedNavigationDisabled_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) { - // when streaming started, we always render page under "not-found" path - string query = useCustomNotFoundPage ? "?useCustomNotFoundPage=true" : ""; - Navigate($"{ServerPathBase}/streaming-set-not-found{query}"); + EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, true, skipNavigation: true); + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/set-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertUrlChanged(testUrl); + } - string expectedTitle = "Default Not Found Page"; - Browser.Equal(expectedTitle, () => Browser.Title); + private void AssertNotFoundRendered_ResponseStarted_Or_POST(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet, string testUrl) + { + if (hasCustomNotFoundPageSet) + { + AssertNotFoundPageRendered(); + } + else if (hasReExecutionMiddleware) + { + AssertReExecutionPageRendered(); + } + else + { + // this throws an exception logged on the server + AssertNotFoundContentNotRendered(); + } + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnFormSubmit_ResponseNotStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/post-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + Browser.FindElement(By.Id("not-found-form")).FindElement(By.TagName("button")).Click(); + + AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertUrlNotChanged(testUrl); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void NotFoundSetOnFormSubmit_ResponseStarted_SSR(bool hasReExecutionMiddleware, bool hasCustomNotFoundPageSet) + { + string reexecution = hasReExecutionMiddleware ? "/reexecution" : ""; + string testUrl = $"{ServerPathBase}{reexecution}/post-not-found-ssr-streaming?useCustomNotFoundPage={hasCustomNotFoundPageSet}"; + Navigate(testUrl); + Browser.FindElement(By.Id("not-found-form")).FindElement(By.TagName("button")).Click(); + + AssertNotFoundRendered_ResponseStarted_Or_POST(hasReExecutionMiddleware, hasCustomNotFoundPageSet, testUrl); + AssertUrlNotChanged(testUrl); + } + + private void AssertNotFoundFragmentRendered() => + Browser.Equal("There's nothing here", () => Browser.FindElement(By.Id("not-found-fragment")).Text); + + private void AssertNotFoundContentNotRendered() => + Browser.Equal("Any content", () => Browser.FindElement(By.Id("test-info")).Text); + + [Fact] + public void StatusCodePagesWithReExecution() + { + Navigate($"{ServerPathBase}/reexecution/trigger-404"); + Browser.Equal("Re-executed page", () => Browser.Title); } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs deleted file mode 100644 index 58ac90b39bbe..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Components.TestServer.RazorComponents; -using Components.TestServer.RazorComponents.Pages.StreamingRendering; -using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; -using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; -using Microsoft.AspNetCore.E2ETesting; -using OpenQA.Selenium; -using TestServer; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; - -public class StatusCodePagesTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture, ITestOutputHelper output) - : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output) -{ - - [Theory] - [InlineData(false, false)] - [InlineData(true, false)] - [InlineData(true, true)] - public void StatusCodePagesWithReExecution(bool streaming, bool responseStarted) - { - string streamingPath = streaming ? "streaming-" : ""; - Navigate($"{ServerPathBase}/reexecution/{streamingPath}set-not-found?responseStarted={responseStarted}"); - - // streaming when response started does not re-execute - string expectedTitle = responseStarted - ? "Default Not Found Page" - : "Re-executed page"; - Browser.Equal(expectedTitle, () => Browser.Title); - } -} diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index 0595baa95bc0..7a1163de31c8 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -37,11 +37,7 @@ public void CanRenderNotFoundInteractive(string renderingMode, bool useCustomNot if (useCustomNotFoundPage) { - var infoText = Browser.FindElement(By.Id("test-info")).Text; - Assert.Contains("Welcome On Custom Not Found Page", infoText); - // custom page should have a custom layout - var aboutLink = Browser.FindElement(By.Id("about-link")).Text; - Assert.Contains("About", aboutLink); + AssertNotFoundPageRendered(); } else { @@ -140,4 +136,68 @@ public void CanNavigateBetweenStaticPagesViaEnhancedNav() Browser.Equal("Global interactivity page: Static via attribute", () => h1.Text); Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanRenderNotFoundPage_SSR(bool streamingStarted) + { + string streamingPath = streamingStarted ? "-streaming" : ""; + Navigate($"{ServerPathBase}/set-not-found-ssr{streamingPath}?useCustomNotFoundPage=true"); + AssertNotFoundPageRendered(); + } + + [Theory] + [InlineData("ServerNonPrerendered")] + [InlineData("WebAssemblyNonPrerendered")] + public void CanRenderNotFoundPage_Interactive(string renderMode) + { + Navigate($"{ServerPathBase}/set-not-found?useCustomNotFoundPage=true&renderMode={renderMode}"); + AssertNotFoundPageRendered(); + } + + private void AssertNotFoundPageRendered() + { + Browser.Equal("Welcome On Custom Not Found Page", () => Browser.FindElement(By.Id("test-info")).Text); + // custom page should have a custom layout + Browser.Equal("About", () => Browser.FindElement(By.Id("about-link")).Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanRenderNotFoundIfNotFoundPageTypeNotProvided_SSR(bool streamingStarted) + { + string streamingPath = streamingStarted ? "-streaming" : ""; + Navigate($"{ServerPathBase}/reexecution/set-not-found-ssr{streamingPath}"); + if (streamingStarted) + { + AssertReExecutedPageRendered(); + } + else + { + AssertNotFoundFragmentRendered(); + } + } + + [Theory] + [InlineData("ServerNonPrerendered")] + [InlineData("WebAssemblyNonPrerendered")] + public void DoesNotReExecuteIf404WasHandled_Interactive(string renderMode) + { + Navigate($"{ServerPathBase}/reexecution/set-not-found?renderMode={renderMode}"); + AssertNotFoundFragmentRendered(); + } + + private void AssertNotFoundFragmentRendered() => + Browser.Equal("There's nothing here", () => Browser.FindElement(By.Id("not-found-fragment")).Text); + + [Fact] + public void StatusCodePagesWithReExecution() + { + Navigate($"{ServerPathBase}/reexecution/trigger-404"); + AssertReExecutedPageRendered(); + } + private void AssertReExecutedPageRendered() => + Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => Browser.Exists(By.Id("test-info")).Text); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index 4520d9202397..92fe46b53a3a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -50,6 +50,15 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.Map("/reexecution", reexecutionApp => { + app.Map("/trigger-404", trigger404App => + { + trigger404App.Run(async context => + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Triggered a 404 status code."); + }); + }); + if (!env.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); @@ -62,7 +71,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) reexecutionApp.UseAntiforgery(); reexecutionApp.UseEndpoints(endpoints => { - endpoints.MapRazorComponents<TRootComponent>(); + endpoints.MapRazorComponents<TRootComponent>() + .AddAdditionalAssemblies(Assembly.Load("TestContentPackage")); }); }); @@ -83,7 +93,8 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen app.UseAntiforgery(); app.UseEndpoints(endpoints => { - endpoints.MapRazorComponents<TRootComponent>(); + endpoints.MapRazorComponents<TRootComponent>() + .AddAdditionalAssemblies(Assembly.Load("TestContentPackage")); }); } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 4bb4bee5014c..dacdc2b72b5a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -88,6 +88,14 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.Map("/reexecution", reexecutionApp => { + app.Map("/trigger-404", app => + { + app.Run(async context => + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Triggered a 404 status code."); + }); + }); reexecutionApp.UseStatusCodePagesWithReExecute("/not-found-reexecute", createScopeForErrors: true); reexecutionApp.UseRouting(); @@ -138,6 +146,7 @@ private void ConfigureEndpoints(IApplicationBuilder app, IWebHostEnvironment env } _ = endpoints.MapRazorComponents<TRootComponent>() + .AddAdditionalAssemblies(Assembly.Load("TestContentPackage")) .AddAdditionalAssemblies(Assembly.Load("Components.WasmMinimal")) .AddInteractiveServerRenderMode(options => { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index ace8d627d941..2219da3955d2 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -1,5 +1,6 @@ @using Components.TestServer.RazorComponents.Pages.Forms @using Components.WasmMinimal.Pages.NotFound +@using TestContentPackage.NotFound @code { [Parameter] @@ -10,7 +11,7 @@ protected override void OnParametersSet() { - if (UseCustomNotFoundPage == "true") + if (string.Equals(UseCustomNotFoundPage, "true", StringComparison.OrdinalIgnoreCase)) { NotFoundPageType = typeof(CustomNotFoundPage); } @@ -29,12 +30,12 @@ <HeadOutlet /> </head> <body> - <Router AppAssembly="@typeof(App).Assembly" NotFoundPage="NotFoundPageType"> + <Router AppAssembly="@typeof(App).Assembly" AdditionalAssemblies="new[] { typeof(TestContentPackage.NotFound.NotFoundPage).Assembly }" NotFoundPage="NotFoundPageType"> <Found Context="routeData"> <RouteView RouteData="@routeData" /> <FocusOnNavigate RouteData="@routeData" Selector="[data-focus-on-navigate]" /> </Found> - <NotFound>There's nothing here</NotFound> + <NotFound><p id="not-found-fragment">There's nothing here</p></NotFound> </Router> <script> // This script must come before blazor.web.js to test that diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/PageThatSetsNotFound.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/PageThatSetsNotFound.razor deleted file mode 100644 index e397f81672a3..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/PageThatSetsNotFound.razor +++ /dev/null @@ -1,23 +0,0 @@ -@page "/reexecution/set-not-found" -@page "/set-not-found" -@attribute [StreamRendering(false)] -@inject NavigationManager NavigationManager - -<PageTitle>Original page</PageTitle> - -<p id="test-info">Any content</p> - -@code{ - [Parameter] - [SupplyParameterFromQuery(Name = "shouldSet")] - public bool? ShouldSet { get; set; } - - protected override void OnInitialized() - { - bool shouldSet = ShouldSet ?? true; - if (shouldSet) - { - NavigationManager.NotFound(); - } - } -} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/LargeStreamRendering.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/LargeStreamRendering.razor index 9b6d199c76ee..99ea274e45d2 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/LargeStreamRendering.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/LargeStreamRendering.razor @@ -2,6 +2,8 @@ @attribute [StreamRendering] +<PageTitle>Streaming</PageTitle> + <h1>Streaming Rendering</h1> <NotEnabledStreamingRenderingComponent UseLargeItems="true"/> diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingRendering.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingRendering.razor index 0bd36fd4817c..64f85052ae2d 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingRendering.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingRendering.razor @@ -2,6 +2,8 @@ @attribute [StreamRendering] +<PageTitle>Streaming</PageTitle> + <h1>Streaming Rendering</h1> <NotEnabledStreamingRenderingComponent /> diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor deleted file mode 100644 index 92b5f95d7c4b..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor +++ /dev/null @@ -1,30 +0,0 @@ -@page "/reexecution/streaming-set-not-found" -@page "/streaming-set-not-found" -@attribute [StreamRendering] -@inject NavigationManager NavigationManager - -@code { - [Parameter] - [SupplyParameterFromQuery(Name = "shouldSet")] - public bool? ShouldSet { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "responseStarted")] - public bool? ResponseStarted { get; set; } - - protected override async Task OnInitializedAsync() - { - bool shouldSet = ShouldSet ?? true; - bool responseStarted = ResponseStarted ?? true; - if (responseStarted) - { - // Simulate some delay before triggering NotFound to start streaming response - await Task.Yield(); - } - - if (shouldSet) - { - NavigationManager.NotFound(); - } - } -} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/PageThatSetsNotFound-Interactive.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/PageThatSetsNotFound-Interactive.razor new file mode 100644 index 000000000000..fe0362ccda63 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/PageThatSetsNotFound-Interactive.razor @@ -0,0 +1,28 @@ +@page "/reexecution/set-not-found" +@page "/set-not-found" + +@* + this page is used only in global interactivity scenarios + the component's content will be rendered when it becomes interactive +*@ + +<TestContentPackage.NotFound.ComponentThatSetsNotFound @rendermode="@RenderModeHelper.GetRenderMode(_renderMode)" WaitForInteractivity="true" /> + +@code{ + [Parameter, SupplyParameterFromQuery(Name = "renderMode")] + public string? RenderModeStr { get; set; } + + private RenderModeId _renderMode; + + protected override void OnInitialized() + { + if (!string.IsNullOrEmpty(RenderModeStr)) + { + _renderMode = RenderModeHelper.ParseRenderMode(RenderModeStr); + } + else + { + throw new ArgumentException("RenderModeStr cannot be null or empty. Did you mean to redirect to /set-not-found-ssr?", nameof(RenderModeStr)); + } + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/Components.WasmMinimal/Routes.razor b/src/Components/test/testassets/Components.WasmMinimal/Routes.razor index 92f41cc8b4f1..010176d437e7 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Routes.razor +++ b/src/Components/test/testassets/Components.WasmMinimal/Routes.razor @@ -1,5 +1,6 @@ @using Microsoft.AspNetCore.Components.Routing @using Components.WasmMinimal.Pages.NotFound +@using TestContentPackage.NotFound @inject NavigationManager NavigationManager @code { @@ -11,7 +12,7 @@ protected override void OnParametersSet() { - if (UseCustomNotFoundPage == "true") + if (string.Equals(UseCustomNotFoundPage, "true", StringComparison.OrdinalIgnoreCase)) { NotFoundPageType = typeof(CustomNotFoundPage); } @@ -22,10 +23,10 @@ } } -<Router AppAssembly="@typeof(Program).Assembly" NotFoundPage="NotFoundPageType"> +<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(TestContentPackage.NotFound.NotFoundPage).Assembly }" NotFoundPage="NotFoundPageType"> <Found Context="routeData"> <RouteView RouteData="@routeData" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" /> </Found> - <NotFound>There's nothing here</NotFound> + <NotFound><p id="not-found-fragment">There's nothing here</p></NotFound> </Router> diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatPostsNotFound.razor b/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatPostsNotFound.razor new file mode 100644 index 000000000000..f9b6cd9c7a3c --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatPostsNotFound.razor @@ -0,0 +1,32 @@ +@using Microsoft.AspNetCore.Components.Forms + +@inject NavigationManager NavigationManager + +@if (!WaitForInteractivity || RendererInfo.IsInteractive) +{ + <PageTitle>Original page</PageTitle> + + <form method="post" id="not-found-form" @onsubmit="HandleSubmit" @formname="PostNotFoundForm"> + <AntiforgeryToken /> + <button type="submit">Trigger NotFound</button> + </form> + + <p id="test-info">Any content</p> +} + +@code{ + [Parameter] + public bool StartStreaming { get; set; } = false; + + [Parameter] + public bool WaitForInteractivity { get; set; } = false; + + private async Task HandleSubmit() + { + if (StartStreaming) + { + await Task.Yield(); + } + NavigationManager.NotFound(); + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatSetsNotFound.razor b/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatSetsNotFound.razor new file mode 100644 index 000000000000..fe024d3bb8d0 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/ComponentThatSetsNotFound.razor @@ -0,0 +1,26 @@ +@inject NavigationManager NavigationManager + +@if (!WaitForInteractivity || RendererInfo.IsInteractive) +{ + <PageTitle>Original page</PageTitle> + + <p id="test-info">Any content</p> + +} + +@code{ + [Parameter] + public bool StartStreaming { get; set; } = false; + + [Parameter] + public bool WaitForInteractivity { get; set; } = false; + + protected async override Task OnInitializedAsync() + { + if (StartStreaming) + { + await Task.Yield(); + } + NavigationManager.NotFound(); + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/CustomNotFoundPage.razor b/src/Components/test/testassets/TestContentPackage/NotFound/CustomNotFoundPage.razor similarity index 100% rename from src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/CustomNotFoundPage.razor rename to src/Components/test/testassets/TestContentPackage/NotFound/CustomNotFoundPage.razor diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/Index.razor b/src/Components/test/testassets/TestContentPackage/NotFound/Index.razor new file mode 100644 index 000000000000..5d0bd8b17ddc --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/Index.razor @@ -0,0 +1,89 @@ +@page "/not-found-index" + +@using Microsoft.AspNetCore.Components.Forms + +@inject NavigationManager NavigationManager + +<h1 id="test-info">List of Not Found test pages</h1> + +<h2>Link navigation (enhanced nav passed in Headers)</h2> +<ul> + <li><a href="@PostNotFoundNoStreaming">@PostNotFoundNoStreamingText</a></li> + <li><a href="@PostNotFoundNoStreamingReexecution">@PostNotFoundNoStreamingReexecutionText</a></li> + + <li><a href="@PostNotFoundStreaming">@PostNotFoundStreamingText</a></li> + <li><a href="@PostNotFoundStreamingReexecution">@PostNotFoundStreamingReexecutionText</a></li> + + <li><a href="@SetNotFoundNoStreaming">@SetNotFoundNoStreamingText</a></li> + <li><a href="@SetNotFoundNoStreamingReexecution">@SetNotFoundNoStreamingReexecutionText</a></li> + + <li><a href="@SetNotFoundStreaming">@SetNotFoundStreamingText</a></li> + <li><a href="@SetNotFoundStreamingReexecution">@SetNotFoundStreamingReexecutionText</a></li> +</ul> + +<h2>Programmatic navigation in a form (enhanced nav not passed in Headers)</h2> +<form method="post" @onsubmit="() => NavigateTo(PostNotFoundNoStreaming)" @formname="ProgrammaticPostNotFoundNoStreamingForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @PostNotFoundNoStreamingText</button> +</form> +<form method="post" @onsubmit="() => NavigateTo(PostNotFoundNoStreamingReexecution)" @formname="ProgrammaticPostNotFoundNoStreamingReexecutionForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @PostNotFoundNoStreamingReexecutionText</button> +</form> + +<form method="post" @onsubmit="() => NavigateTo(PostNotFoundStreaming)" @formname="ProgrammaticPostNotFoundStreamingForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @PostNotFoundStreamingText</button> +</form> +<form method="post" @onsubmit="() => NavigateTo(PostNotFoundStreamingReexecution)" @formname="ProgrammaticPostNotFoundStreamingReexecutionForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @PostNotFoundStreamingReexecutionText</button> +</form> + +<form method="post" @onsubmit="() => NavigateTo(SetNotFoundNoStreaming)" @formname="ProgrammaticSetNotFoundNoStreamingForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @SetNotFoundNoStreamingText</button> +</form> +<form method="post" @onsubmit="() => NavigateTo(SetNotFoundNoStreamingReexecution)" @formname="ProgrammaticSetNotFoundNoStreamingReexecutionForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @SetNotFoundNoStreamingReexecutionText</button> +</form> + +<form method="post" @onsubmit="() => NavigateTo(SetNotFoundStreaming)" @formname="ProgrammaticSetNotFoundStreamingForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @SetNotFoundStreamingText</button> +</form> +<form method="post" @onsubmit="() => NavigateTo(SetNotFoundStreamingReexecution)" @formname="ProgrammaticSetNotFoundStreamingReexecutionForm"> + <AntiforgeryToken /> + <button type="submit">Programmatic navigate to @SetNotFoundStreamingReexecutionText</button> +</form> + +@code { + [SupplyParameterFromQuery] + public bool useCustomNotFoundPage { get; set; } + + private string AppendQuery(string url) => $"{url}?useCustomNotFoundPage={useCustomNotFoundPage}"; + + private string PostNotFoundNoStreaming => AppendQuery("post-not-found-ssr"); + private string PostNotFoundNoStreamingReexecution => AppendQuery($"reexecution/post-not-found-ssr"); + private string PostNotFoundStreaming => AppendQuery("post-not-found-ssr-streaming"); + private string PostNotFoundStreamingReexecution => AppendQuery($"reexecution/post-not-found-ssr-streaming"); + private string SetNotFoundNoStreaming => AppendQuery("set-not-found-ssr"); + private string SetNotFoundNoStreamingReexecution => AppendQuery($"reexecution/set-not-found-ssr"); + private string SetNotFoundStreaming => AppendQuery("set-not-found-ssr-streaming"); + private string SetNotFoundStreamingReexecution => AppendQuery($"reexecution/set-not-found-ssr-streaming"); + + private const string PostNotFoundNoStreamingText = "PageThatPostsNotFound-no-streaming"; + private const string PostNotFoundNoStreamingReexecutionText = $"{PostNotFoundNoStreamingText} with reexecution"; + private const string PostNotFoundStreamingText = "PageThatPostsNotFound-streaming"; + private const string PostNotFoundStreamingReexecutionText = $"{PostNotFoundStreamingText} with reexecution"; + private const string SetNotFoundNoStreamingText = "PageThatSetsNotFound-no-streaming"; + private const string SetNotFoundNoStreamingReexecutionText = $"{SetNotFoundNoStreamingText} with reexecution"; + private const string SetNotFoundStreamingText = "PageThatSetsNotFound-streaming"; + private const string SetNotFoundStreamingReexecutionText = $"{SetNotFoundStreamingText} with reexecution"; + + private void NavigateTo(string url) + { + NavigationManager.NavigateTo(url); + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/NotFoundLayout.razor b/src/Components/test/testassets/TestContentPackage/NotFound/NotFoundLayout.razor similarity index 100% rename from src/Components/test/testassets/Components.WasmMinimal/Pages/NotFound/NotFoundLayout.razor rename to src/Components/test/testassets/TestContentPackage/NotFound/NotFoundLayout.razor diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/NotFoundPage.razor b/src/Components/test/testassets/TestContentPackage/NotFound/NotFoundPage.razor similarity index 100% rename from src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/NotFoundPage.razor rename to src/Components/test/testassets/TestContentPackage/NotFound/NotFoundPage.razor diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-no-streaming.razor b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-no-streaming.razor new file mode 100644 index 000000000000..4aa3a245b047 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-no-streaming.razor @@ -0,0 +1,11 @@ +@page "/reexecution/post-not-found-ssr" +@page "/post-not-found-ssr" +@attribute [StreamRendering(false)] + +@* + this page is used in global interactivity and no interactivity scenarios + the content is rendered on the server without streaming and might become + interactive later if interactivity was enabled in the app +*@ + +<TestContentPackage.NotFound.ComponentThatPostsNotFound /> \ No newline at end of file diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-streaming.razor b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-streaming.razor new file mode 100644 index 000000000000..a542c35c52f4 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatPostsNotFound-streaming.razor @@ -0,0 +1,11 @@ +@page "/reexecution/post-not-found-ssr-streaming" +@page "/post-not-found-ssr-streaming" +@attribute [StreamRendering(true)] + +@* + this page is used in global interactivity and no interactivity scenarios + the content is rendered on the server with streaming and might become + interactive later if interactivity was enabled in the app +*@ + +<TestContentPackage.NotFound.ComponentThatPostsNotFound StartStreaming="true"/> \ No newline at end of file diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-no-streaming.razor b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-no-streaming.razor new file mode 100644 index 000000000000..b99ed19711d7 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-no-streaming.razor @@ -0,0 +1,11 @@ +@page "/reexecution/set-not-found-ssr" +@page "/set-not-found-ssr" +@attribute [StreamRendering(false)] + +@* + this page is used in global interactivity and no interactivity scenarios + the content is rendered on the server without streaming and might become + interactive later if interactivity was enabled in the app +*@ + +<TestContentPackage.NotFound.ComponentThatSetsNotFound /> \ No newline at end of file diff --git a/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-streaming.razor b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-streaming.razor new file mode 100644 index 000000000000..e3124758ce65 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/NotFound/PageThatSetsNotFound-streaming.razor @@ -0,0 +1,11 @@ +@page "/reexecution/set-not-found-ssr-streaming" +@page "/set-not-found-ssr-streaming" +@attribute [StreamRendering(true)] + +@* + this page is used in global interactivity and no interactivity scenarios + the content is rendered on the server with streaming and might become + interactive later if interactivity was enabled in the app +*@ + +<TestContentPackage.NotFound.ComponentThatSetsNotFound StartStreaming="true"/> \ No newline at end of file diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/ReexecutedPage.razor b/src/Components/test/testassets/TestContentPackage/NotFound/ReexecutedPage.razor similarity index 100% rename from src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFound/ReexecutedPage.razor rename to src/Components/test/testassets/TestContentPackage/NotFound/ReexecutedPage.razor diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs index 6aa6843995b8..952753c69830 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs @@ -187,9 +187,11 @@ public static IApplicationBuilder UseStatusCodePagesWithReExecute( { var newNext = RerouteHelper.Reroute(app, routeBuilder, next); return new StatusCodePagesMiddleware(next, - Options.Create(new StatusCodePagesOptions() { + Options.Create(new StatusCodePagesOptions() + { HandleAsync = CreateHandler(pathFormat, queryFormat, newNext), - CreateScopeForErrors = createScopeForErrors + CreateScopeForErrors = createScopeForErrors, + PathFormat = pathFormat })).Invoke; }); } @@ -197,7 +199,8 @@ public static IApplicationBuilder UseStatusCodePagesWithReExecute( var options = new StatusCodePagesOptions { HandleAsync = CreateHandler(pathFormat, queryFormat), - CreateScopeForErrors = createScopeForErrors + CreateScopeForErrors = createScopeForErrors, + PathFormat = pathFormat }; var wrappedOptions = new OptionsWrapper<StatusCodePagesOptions>(options); return app.UseMiddleware<StatusCodePagesMiddleware>(wrappedOptions); diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesMiddleware.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesMiddleware.cs index 87994f6c7011..7b821aa01b2e 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesMiddleware.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesMiddleware.cs @@ -48,8 +48,14 @@ public async Task Invoke(HttpContext context) statusCodeFeature.Enabled = false; } + // Attach pathFormat to HttpContext.Items early in the pipeline + context.Items[nameof(StatusCodePagesOptions)] = _options.PathFormat; + await _next(context); + // Remove pathFormat from HttpContext.Items after handler execution + context.Items.Remove(nameof(StatusCodePagesOptions)); + if (!statusCodeFeature.Enabled) { // Check if the feature is still available because other middleware (such as a web API written in MVC) could diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs index fba436b01442..95ff38eed8d5 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs @@ -62,4 +62,6 @@ private static string BuildResponseBody(int httpStatusCode) /// </summary> /// <remarks>The default value is <see langword="false"/>.</remarks> public bool CreateScopeForErrors { get; set; } + + internal string? PathFormat { get; set; } } diff --git a/src/Shared/E2ETesting/WaitAssert.cs b/src/Shared/E2ETesting/WaitAssert.cs index 71c8ae6b7c41..a3bb53520f30 100644 --- a/src/Shared/E2ETesting/WaitAssert.cs +++ b/src/Shared/E2ETesting/WaitAssert.cs @@ -37,6 +37,11 @@ public static void True(this IWebDriver driver, Func<bool> actual, TimeSpan time public static void True(this IWebDriver driver, Func<bool> actual, TimeSpan timeout, string message) => WaitAssertCore(driver, () => Assert.True(actual(), message), timeout); + public static void True(this IWebDriver driver, Func<bool> actual, string message) + => WaitAssertCore(driver, () => Assert.True(actual(), message)); + + public static void False(this IWebDriver driver, Func<bool> actual, string message) + => WaitAssertCore(driver, () => Assert.False(actual(), message)); public static void False(this IWebDriver driver, Func<bool> actual) => WaitAssertCore(driver, () => Assert.False(actual()));