diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 5b2d8d1cec3f..21c0226e2ef5 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ #nullable enable Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler! 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() -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void @@ -11,3 +12,4 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.SignalRendererToFinishRendering() -> void \ No newline at end of file diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e6c3c240c538..8f9295f15886 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -44,6 +44,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private bool _rendererIsDisposed; private bool _hotReloadInitialized; + private bool _rendererIsStopped; /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. @@ -658,6 +659,12 @@ internal void AddToRenderQueue(int componentId, RenderFragment renderFragment) { Dispatcher.AssertAccess(); + if (_rendererIsStopped) + { + // Once we're stopped, we'll disregard further attempts to queue anything + return; + } + var componentState = GetOptionalComponentState(componentId); if (componentState == null) { @@ -724,14 +731,22 @@ private ComponentState GetRequiredRootComponentState(int componentId) return componentState; } + /// + /// Stop adding render requests to the render queue. + /// + protected virtual void SignalRendererToFinishRendering() + { + _rendererIsStopped = true; + } + /// /// Processes pending renders requests from components if there are any. /// protected virtual void ProcessPendingRender() { - if (_rendererIsDisposed) + if (_rendererIsDisposed || _rendererIsStopped) { - // Once we're disposed, we'll disregard further attempts to render anything + // Once we're disposed or stopped, we'll disregard further attempts to render anything return; } diff --git a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs index cbe9274f9792..c1488a9b7bc3 100644 --- a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs +++ b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs @@ -15,4 +15,13 @@ public interface IHostEnvironmentNavigationManager /// The base URI. /// The absolute URI. void Initialize(string baseUri, string uri); + + /// + /// Initializes the . + /// + /// The base URI. + /// The absolute URI. + /// A delegate that points to a method handling navigation events. + void Initialize(string baseUri, string uri, Func onNavigateTo) => + Initialize(baseUri, uri); } diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index e9bb98f2d1c2..becfe7feeaa9 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,11 +7,40 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { + private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException"; + + private static bool _throwNavigationException => + AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; + + private Func? _onNavigateTo; + void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri) => Initialize(baseUri, uri); + void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri, Func onNavigateTo) + { + _onNavigateTo = onNavigateTo; + Initialize(baseUri, uri); + } + protected override void NavigateToCore(string uri, NavigationOptions options) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - throw new NavigationException(absoluteUriString); + if (_throwNavigationException) + { + throw new NavigationException(absoluteUriString); + } + else + { + _ = PerformNavigationAsync(); + } + + async Task PerformNavigationAsync() + { + if (_onNavigateTo == null) + { + throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized."); + } + await _onNavigateTo(absoluteUriString); + } } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index 2b8455741f52..e30852703324 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -1,11 +1,14 @@ // 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.Components.Endpoints.Rendering; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.Buffers; using System.Globalization; using System.Linq; using System.Text; @@ -84,6 +87,23 @@ private void SetNotFoundResponse(object? sender, EventArgs args) SignalRendererToFinishRendering(); } + private async Task OnNavigateTo(string uri) + { + if (_httpContext.Response.HasStarted) + { + 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); + HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, uri); + await bufferWriter.FlushAsync(); + } + else + { + await HandleNavigationBeforeResponseStarted(_httpContext, uri); + } + SignalRendererToFinishRendering(); + } + private void UpdateNamedSubmitEvents(in RenderBatch renderBatch) { if (renderBatch.NamedEventChanges is { } changes) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index e331fd5707c2..66ff3dfd9587 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -206,7 +206,15 @@ public static ValueTask HandleNavigationExcepti "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", navigationException); } - else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location) + else + { + return HandleNavigationBeforeResponseStarted(httpContext, navigationException.Location); + } + } + + private static ValueTask HandleNavigationBeforeResponseStarted(HttpContext httpContext, string destinationLocation) + { + if (IsPossibleExternalDestination(httpContext.Request, destinationLocation) && IsProgressivelyEnhancedNavigation(httpContext.Request)) { // For progressively-enhanced nav, we prefer to use opaque redirections for external URLs rather than @@ -214,12 +222,12 @@ public static ValueTask HandleNavigationExcepti // duplicated request. The client can't rely on receiving this header, though, since non-Blazor endpoints // wouldn't return it. httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location", - OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, navigationException.Location)); + OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, destinationLocation)); return new ValueTask(PrerenderedComponentHtmlContent.Empty); } else { - httpContext.Response.Redirect(navigationException.Location); + httpContext.Response.Redirect(destinationLocation); return new ValueTask(PrerenderedComponentHtmlContent.Empty); } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 965858d807c0..1bc4c40ce0a4 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -79,7 +79,7 @@ internal async Task InitializeStandardComponentServicesAsync( IFormCollection? form = null) { var navigationManager = httpContext.RequestServices.GetRequiredService(); - ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request)); + ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo); if (navigationManager != null) { @@ -176,21 +176,10 @@ protected override void AddPendingTask(ComponentState? componentState, Task task base.AddPendingTask(componentState, task); } - private void SignalRendererToFinishRendering() + protected override 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(); + base.SignalRendererToFinishRendering(); } // For tests only diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index e54298c42208..7bd648c783c1 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -828,9 +828,12 @@ public async Task Rendering_ComponentWithJsInteropThrows() exception.Message); } - [Fact] - public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted(bool expectException) { + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: expectException); // Arrange var ctx = new DefaultHttpContext(); ctx.Request.Scheme = "http"; @@ -838,25 +841,49 @@ public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponse ctx.Request.PathBase = "/base"; ctx.Request.Path = "/path"; ctx.Request.QueryString = new QueryString("?query=value"); + ctx.Response.Body = new MemoryStream(); var responseMock = new Mock(); responseMock.Setup(r => r.HasStarted).Returns(true); ctx.Features.Set(responseMock.Object); var httpContext = GetHttpContext(ctx); + string redirectUri = "http://localhost/redirect"; // Act - var exception = await Assert.ThrowsAsync(async () => await renderer.PrerenderComponentAsync( - httpContext, - typeof(RedirectComponent), - null, - ParameterView.FromDictionary(new Dictionary - { - { "RedirectUri", "http://localhost/redirect" } - }))); + if (expectException) + { + var exception = await Assert.ThrowsAsync(async () => await renderer.PrerenderComponentAsync( + httpContext, + typeof(RedirectComponent), + null, + ParameterView.FromDictionary(new Dictionary + { + { "RedirectUri", redirectUri } + }))); - Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " + - "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + - "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", - exception.Message); + Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " + + "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + + "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", + exception.Message); + } + else + { + await renderer.PrerenderComponentAsync( + httpContext, + typeof(RedirectComponent), + null, + ParameterView.FromDictionary(new Dictionary + { + { "RedirectUri", redirectUri } + })); + // read the custom element from the response body + httpContext.Response.Body.Position = 0; + var reader = new StreamReader(httpContext.Response.Body); + var output = await reader.ReadToEndAsync(); + + // Assert that the output contains expected navigation instructions. + var pattern = "^