diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs index 02731095e902..60fc5413faa6 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs @@ -55,8 +55,16 @@ internal void AddEndpoints( // The display name is for debug purposes by endpoint routing. builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})"; - builder.RequestDelegate = RazorComponentEndpoint.CreateRouteDelegate(pageDefinition.Type); + builder.RequestDelegate = CreateRouteDelegate(pageDefinition.Type); endpoints.Add(builder.Build()); } + + private static RequestDelegate CreateRouteDelegate(Type componentType) + { + return httpContext => + { + return new RazorComponentEndpointInvoker(httpContext, componentType).RenderComponent(); + }; + } } diff --git a/src/Components/Endpoints/src/RazorComponentEndpoint.cs b/src/Components/Endpoints/src/RazorComponentEndpoint.cs deleted file mode 100644 index aecfc38804c3..000000000000 --- a/src/Components/Endpoints/src/RazorComponentEndpoint.cs +++ /dev/null @@ -1,79 +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.Buffers; -using System.Text; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.AspNetCore.Components.Endpoints; - -internal static class RazorComponentEndpoint -{ - public static RequestDelegate CreateRouteDelegate(Type componentType) - { - return httpContext => - { - httpContext.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; - return RenderComponentToResponse(httpContext, RenderMode.Static, componentType, componentParameters: null, preventStreamingRendering: false); - }; - } - - internal static Task RenderComponentToResponse( - HttpContext httpContext, - RenderMode renderMode, - Type componentType, - IReadOnlyDictionary? componentParameters, - bool preventStreamingRendering) - { - var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService(); - return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => - { - // We could pool these dictionary instances if we wanted, and possibly even the ParameterView - // backing buffers could come from a pool like they do during rendering. - var hostParameters = ParameterView.FromDictionary(new Dictionary - { - { nameof(RazorComponentEndpointHost.RenderMode), renderMode }, - { nameof(RazorComponentEndpointHost.ComponentType), componentType }, - { nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters }, - }); - - await using var writer = CreateResponseWriter(httpContext.Response.Body); - - // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, - // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host - // component takes care of switching into your desired render mode when it produces its own output. - var htmlContent = (EndpointHtmlRenderer.PrerenderedComponentHtmlContent)(await endpointHtmlRenderer.PrerenderComponentAsync( - httpContext, - typeof(RazorComponentEndpointHost), - RenderMode.Static, - hostParameters, - waitForQuiescence: preventStreamingRendering)); - - // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) - // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent - // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the - // renderer sync context and cause a batch that would get missed. - htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above - - if (!htmlContent.QuiescenceTask.IsCompleted) - { - await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer); - } - - // 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. - await writer.FlushAsync(); - }); - } - - private static TextWriter CreateResponseWriter(Stream bodyStream) - { - // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize - const int DefaultBufferSize = 16 * 1024; - return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); - } -} diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs new file mode 100644 index 000000000000..40c67538a276 --- /dev/null +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class RazorComponentEndpointInvoker +{ + private readonly HttpContext _context; + private readonly EndpointHtmlRenderer _renderer; + private readonly Type _componentType; + + public RazorComponentEndpointInvoker(HttpContext context, Type componentType) + { + _context = context; + _renderer = _context.RequestServices.GetRequiredService(); + _componentType = componentType; + } + + public Task RenderComponent() + { + return _renderer.Dispatcher.InvokeAsync(RenderComponentCore); + } + + private async Task RenderComponentCore() + { + _context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; + + // We could pool these dictionary instances if we wanted, and possibly even the ParameterView + // backing buffers could come from a pool like they do during rendering. + var hostParameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(RazorComponentEndpointHost.RenderMode), RenderMode.Static }, + { nameof(RazorComponentEndpointHost.ComponentType), _componentType }, + { nameof(RazorComponentEndpointHost.ComponentParameters), null }, + }); + + await using var writer = CreateResponseWriter(_context.Response.Body); + + // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, + // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host + // component takes care of switching into your desired render mode when it produces its own output. + var htmlContent = await _renderer.RenderEndpointComponent( + _context, + typeof(RazorComponentEndpointHost), + hostParameters, + waitForQuiescence: false); + + // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) + // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent + // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the + // renderer sync context and cause a batch that would get missed. + htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above + + if (!htmlContent.QuiescenceTask.IsCompleted) + { + await _renderer.SendStreamingUpdatesAsync(_context, htmlContent.QuiescenceTask, writer); + } + + // 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. + await writer.FlushAsync(); + } + + private static TextWriter CreateResponseWriter(Stream bodyStream) + { + // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize + const int DefaultBufferSize = 16 * 1024; + return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + } +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 3ca29cd3bbbf..f57b63d62e80 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -54,38 +54,71 @@ public async ValueTask PrerenderComponentAsync( _ => throw new ArgumentException(Resources.FormatUnsupportedRenderMode(prerenderMode), nameof(prerenderMode)), }; - if (waitForQuiescence) - { - // Full quiescence, i.e., all tasks completed regardless of streaming SSR - await result.QuiescenceTask; - } - else if (_nonStreamingPendingTasks.Count > 0) - { - // Just wait for quiescence of the non-streaming subtrees - await Task.WhenAll(_nonStreamingPendingTasks); - } + await WaitForResultReady(waitForQuiescence, result); return result; } catch (NavigationException navigationException) { - if (httpContext.Response.HasStarted) - { - // If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to - // communicate the redirection to the browser. - // If we are doing streaming SSR, this should not generally happen because if you navigate during the initial - // synchronous render, the response would not yet have started, and if you do so during some later async - // phase, we would already have exited this method since streaming SSR means not awaiting quiescence. - throw new InvalidOperationException( - "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."); - } - else - { - httpContext.Response.Redirect(navigationException.Location); - return PrerenderedComponentHtmlContent.Empty; - } + return await HandleNavigationException(httpContext, navigationException); + } + } + + internal async ValueTask RenderEndpointComponent( + HttpContext httpContext, + Type componentType, + ParameterView parameters, + bool waitForQuiescence) + { + await InitializeStandardComponentServicesAsync(httpContext); + + try + { + var component = BeginRenderingComponent(componentType, parameters); + var result = new PrerenderedComponentHtmlContent(Dispatcher, component, null, null); + + await WaitForResultReady(waitForQuiescence, result); + + return result; + } + catch (NavigationException navigationException) + { + return await HandleNavigationException(httpContext, navigationException); + } + } + + private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedComponentHtmlContent result) + { + if (waitForQuiescence) + { + // Full quiescence, i.e., all tasks completed regardless of streaming SSR + await result.QuiescenceTask; + } + else if (_nonStreamingPendingTasks.Count > 0) + { + // Just wait for quiescence of the non-streaming subtrees + await Task.WhenAll(_nonStreamingPendingTasks); + } + } + + private static ValueTask HandleNavigationException(HttpContext httpContext, NavigationException navigationException) + { + if (httpContext.Response.HasStarted) + { + // If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to + // communicate the redirection to the browser. + // If we are doing streaming SSR, this should not generally happen because if you navigate during the initial + // synchronous render, the response would not yet have started, and if you do so during some later async + // phase, we would already have exited this method since streaming SSR means not awaiting quiescence. + throw new InvalidOperationException( + "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."); + } + else + { + httpContext.Response.Redirect(navigationException.Location); + return new ValueTask(PrerenderedComponentHtmlContent.Empty); } } diff --git a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs index 2e8090f95523..a8918a526d19 100644 --- a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs +++ b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs @@ -1,7 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -30,11 +35,67 @@ public virtual Task ExecuteAsync(HttpContext httpContext, RazorComponentResult r response.StatusCode = result.StatusCode.Value; } - return RazorComponentEndpoint.RenderComponentToResponse( + return RenderComponentToResponse( httpContext, result.RenderMode, result.ComponentType, result.Parameters, result.PreventStreamingRendering); } + + internal static Task RenderComponentToResponse( + HttpContext httpContext, + RenderMode renderMode, + Type componentType, + IReadOnlyDictionary? componentParameters, + bool preventStreamingRendering) + { + var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService(); + return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => + { + // We could pool these dictionary instances if we wanted, and possibly even the ParameterView + // backing buffers could come from a pool like they do during rendering. + var hostParameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(RazorComponentEndpointHost.RenderMode), renderMode }, + { nameof(RazorComponentEndpointHost.ComponentType), componentType }, + { nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters }, + }); + + await using var writer = CreateResponseWriter(httpContext.Response.Body); + + // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, + // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host + // component takes care of switching into your desired render mode when it produces its own output. + var htmlContent = (EndpointHtmlRenderer.PrerenderedComponentHtmlContent)(await endpointHtmlRenderer.PrerenderComponentAsync( + httpContext, + typeof(RazorComponentEndpointHost), + RenderMode.Static, + hostParameters, + waitForQuiescence: preventStreamingRendering)); + + // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) + // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent + // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the + // renderer sync context and cause a batch that would get missed. + htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above + + if (!htmlContent.QuiescenceTask.IsCompleted) + { + await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer); + } + + // 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. + await writer.FlushAsync(); + }); + } + + private static TextWriter CreateResponseWriter(Stream bodyStream) + { + // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize + const int DefaultBufferSize = 16 * 1024; + return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + } } diff --git a/src/Components/Endpoints/test/RazorComponentEndpointTest.cs b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs similarity index 94% rename from src/Components/Endpoints/test/RazorComponentEndpointTest.cs rename to src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs index 44bd2eea0545..9943dfbaadcb 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class RazorComponentEndpointTest +public class RazorComponentResultExecutorTest { [Fact] public async Task CanRenderComponentStatically() @@ -28,7 +28,7 @@ public async Task CanRenderComponentStatically() httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(SimpleComponent), @@ -49,7 +49,7 @@ public async Task PerformsStreamingRendering() httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingAsyncLoadingComponent), @@ -82,7 +82,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAComponentRen httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(DoubleRenderingStreamingAsyncComponent), @@ -115,7 +115,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAnAncestorAls httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentWithChild), @@ -145,7 +145,7 @@ public async Task WaitsForQuiescenceIfPreventStreamingRenderingIsTrue() httpContext.Response.Body = responseBody; // Act/Assert: Doesn't complete until loading finishes - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingAsyncLoadingComponent), @@ -171,7 +171,7 @@ public async Task SupportsLayouts() httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentWithLayout), null, false); @@ -186,7 +186,7 @@ public async Task OnNavigationBeforeResponseStarted_Redirects() var httpContext = GetTestHttpContext(); // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentThatRedirectsSynchronously), null, false); @@ -205,7 +205,7 @@ public async Task OnNavigationAfterResponseStarted_WithStreamingOff_Throws() // Act var ex = await Assert.ThrowsAsync( - () => RazorComponentEndpoint.RenderComponentToResponse( + () => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatRedirectsAsynchronously), null, preventStreamingRendering: true)); @@ -222,7 +222,7 @@ public async Task OnNavigationAfterResponseStarted_WithStreamingOn_EmitsCommand( httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatRedirectsAsynchronously), null, preventStreamingRendering: false); @@ -239,7 +239,7 @@ public async Task OnUnhandledExceptionBeforeResponseStarted_Throws() var httpContext = GetTestHttpContext(); // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentThatThrowsSynchronously), null, false)); @@ -254,7 +254,7 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOff_Thro var httpContext = GetTestHttpContext(); // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatThrowsAsynchronously), null, preventStreamingRendering: true)); @@ -277,7 +277,7 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOn_Emits : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'"; // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatThrowsAsynchronously), null, preventStreamingRendering: false)); @@ -381,7 +381,7 @@ private VaryStreamingScenariosContext PrepareVaryStreamingScenariosTests() { nameof(VaryStreamingScenarios.WithinNestedNonstreamingRegionTask), withinNestedNonstreamingRegionTask.Task }, }; - var quiescence = RazorComponentEndpoint.RenderComponentToResponse( + var quiescence = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(VaryStreamingScenarios), parameters, preventStreamingRendering: false); diff --git a/src/Components/Endpoints/test/RazorComponentResultTest.cs b/src/Components/Endpoints/test/RazorComponentResultTest.cs index 028275577c6f..78c29bed0d48 100644 --- a/src/Components/Endpoints/test/RazorComponentResultTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultTest.cs @@ -40,7 +40,7 @@ public async Task CanRenderComponentStatically() { // Arrange var result = new RazorComponentResult(); - var httpContext = RazorComponentEndpointTest.GetTestHttpContext(); + var httpContext = RazorComponentResultExecutorTest.GetTestHttpContext(); var responseBody = new MemoryStream(); httpContext.Response.Body = responseBody; @@ -62,7 +62,7 @@ public async Task ResponseIncludesStatusCodeAndContentTypeAndHtml() StatusCode = 123, ContentType = "application/test-content-type", }; - var httpContext = RazorComponentEndpointTest.GetTestHttpContext(); + var httpContext = RazorComponentResultExecutorTest.GetTestHttpContext(); var responseBody = new MemoryStream(); httpContext.Response.Body = responseBody;