Skip to content

Commit 7a0d0de

Browse files
authored
[Blazor] Specialize pipeline for MapRazorComponents (#47649)
The current pipeline is common for MapRazorComponents and RazorComponentResults which add extra complexity to MapRazorComponents which we expect to be the common case. The change splits the pipeline in two, so that we can simplify the pipeline for MapRazorComponents, where we can make assumptions that are not possible in RazorComponentResult. This change introduces RazorComponentEndpointInvoker to capture all the required state that is needed for rendering the component endpoint and avoid additional closures from captured state caused by calling Dispatcher.InvokeAsync. We also guarantee that we only call `DispatchAsync` once at the root level to enter the synchronization context. Finally, we skip over all the render modes and preserve prerendered component state mode update, since that's not needed at this level.
1 parent a4d27a1 commit 7a0d0de

File tree

7 files changed

+225
-124
lines changed

7 files changed

+225
-124
lines changed

src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,16 @@ internal void AddEndpoints(
5555
// The display name is for debug purposes by endpoint routing.
5656
builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})";
5757

58-
builder.RequestDelegate = RazorComponentEndpoint.CreateRouteDelegate(pageDefinition.Type);
58+
builder.RequestDelegate = CreateRouteDelegate(pageDefinition.Type);
5959

6060
endpoints.Add(builder.Build());
6161
}
62+
63+
private static RequestDelegate CreateRouteDelegate(Type componentType)
64+
{
65+
return httpContext =>
66+
{
67+
return new RazorComponentEndpointInvoker(httpContext, componentType).RenderComponent();
68+
};
69+
}
6270
}

src/Components/Endpoints/src/RazorComponentEndpoint.cs

Lines changed: 0 additions & 79 deletions
This file was deleted.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
using System.Text;
6+
using System.Text.Encodings.Web;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.WebUtilities;
9+
using Microsoft.Extensions.DependencyInjection;
10+
11+
namespace Microsoft.AspNetCore.Components.Endpoints;
12+
13+
internal class RazorComponentEndpointInvoker
14+
{
15+
private readonly HttpContext _context;
16+
private readonly EndpointHtmlRenderer _renderer;
17+
private readonly Type _componentType;
18+
19+
public RazorComponentEndpointInvoker(HttpContext context, Type componentType)
20+
{
21+
_context = context;
22+
_renderer = _context.RequestServices.GetRequiredService<EndpointHtmlRenderer>();
23+
_componentType = componentType;
24+
}
25+
26+
public Task RenderComponent()
27+
{
28+
return _renderer.Dispatcher.InvokeAsync(RenderComponentCore);
29+
}
30+
31+
private async Task RenderComponentCore()
32+
{
33+
_context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
34+
35+
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
36+
// backing buffers could come from a pool like they do during rendering.
37+
var hostParameters = ParameterView.FromDictionary(new Dictionary<string, object?>
38+
{
39+
{ nameof(RazorComponentEndpointHost.RenderMode), RenderMode.Static },
40+
{ nameof(RazorComponentEndpointHost.ComponentType), _componentType },
41+
{ nameof(RazorComponentEndpointHost.ComponentParameters), null },
42+
});
43+
44+
await using var writer = CreateResponseWriter(_context.Response.Body);
45+
46+
// Note that we always use Static rendering mode for the top-level output from a RazorComponentResult,
47+
// because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host
48+
// component takes care of switching into your desired render mode when it produces its own output.
49+
var htmlContent = await _renderer.RenderEndpointComponent(
50+
_context,
51+
typeof(RazorComponentEndpointHost),
52+
hostParameters,
53+
waitForQuiescence: false);
54+
55+
// Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context)
56+
// in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent
57+
// streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the
58+
// renderer sync context and cause a batch that would get missed.
59+
htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above
60+
61+
if (!htmlContent.QuiescenceTask.IsCompleted)
62+
{
63+
await _renderer.SendStreamingUpdatesAsync(_context, htmlContent.QuiescenceTask, writer);
64+
}
65+
66+
// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
67+
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
68+
// response as part of the Dispose which has a perf impact.
69+
await writer.FlushAsync();
70+
}
71+
72+
private static TextWriter CreateResponseWriter(Stream bodyStream)
73+
{
74+
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
75+
const int DefaultBufferSize = 16 * 1024;
76+
return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
77+
}
78+
}

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -54,38 +54,71 @@ public async ValueTask<IHtmlAsyncContent> PrerenderComponentAsync(
5454
_ => throw new ArgumentException(Resources.FormatUnsupportedRenderMode(prerenderMode), nameof(prerenderMode)),
5555
};
5656

57-
if (waitForQuiescence)
58-
{
59-
// Full quiescence, i.e., all tasks completed regardless of streaming SSR
60-
await result.QuiescenceTask;
61-
}
62-
else if (_nonStreamingPendingTasks.Count > 0)
63-
{
64-
// Just wait for quiescence of the non-streaming subtrees
65-
await Task.WhenAll(_nonStreamingPendingTasks);
66-
}
57+
await WaitForResultReady(waitForQuiescence, result);
6758

6859
return result;
6960
}
7061
catch (NavigationException navigationException)
7162
{
72-
if (httpContext.Response.HasStarted)
73-
{
74-
// If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to
75-
// communicate the redirection to the browser.
76-
// If we are doing streaming SSR, this should not generally happen because if you navigate during the initial
77-
// synchronous render, the response would not yet have started, and if you do so during some later async
78-
// phase, we would already have exited this method since streaming SSR means not awaiting quiescence.
79-
throw new InvalidOperationException(
80-
"A navigation command was attempted during prerendering after the server already started sending the response. " +
81-
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
82-
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.");
83-
}
84-
else
85-
{
86-
httpContext.Response.Redirect(navigationException.Location);
87-
return PrerenderedComponentHtmlContent.Empty;
88-
}
63+
return await HandleNavigationException(httpContext, navigationException);
64+
}
65+
}
66+
67+
internal async ValueTask<PrerenderedComponentHtmlContent> RenderEndpointComponent(
68+
HttpContext httpContext,
69+
Type componentType,
70+
ParameterView parameters,
71+
bool waitForQuiescence)
72+
{
73+
await InitializeStandardComponentServicesAsync(httpContext);
74+
75+
try
76+
{
77+
var component = BeginRenderingComponent(componentType, parameters);
78+
var result = new PrerenderedComponentHtmlContent(Dispatcher, component, null, null);
79+
80+
await WaitForResultReady(waitForQuiescence, result);
81+
82+
return result;
83+
}
84+
catch (NavigationException navigationException)
85+
{
86+
return await HandleNavigationException(httpContext, navigationException);
87+
}
88+
}
89+
90+
private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedComponentHtmlContent result)
91+
{
92+
if (waitForQuiescence)
93+
{
94+
// Full quiescence, i.e., all tasks completed regardless of streaming SSR
95+
await result.QuiescenceTask;
96+
}
97+
else if (_nonStreamingPendingTasks.Count > 0)
98+
{
99+
// Just wait for quiescence of the non-streaming subtrees
100+
await Task.WhenAll(_nonStreamingPendingTasks);
101+
}
102+
}
103+
104+
private static ValueTask<PrerenderedComponentHtmlContent> HandleNavigationException(HttpContext httpContext, NavigationException navigationException)
105+
{
106+
if (httpContext.Response.HasStarted)
107+
{
108+
// If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to
109+
// communicate the redirection to the browser.
110+
// If we are doing streaming SSR, this should not generally happen because if you navigate during the initial
111+
// synchronous render, the response would not yet have started, and if you do so during some later async
112+
// phase, we would already have exited this method since streaming SSR means not awaiting quiescence.
113+
throw new InvalidOperationException(
114+
"A navigation command was attempted during prerendering after the server already started sending the response. " +
115+
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
116+
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.");
117+
}
118+
else
119+
{
120+
httpContext.Response.Redirect(navigationException.Location);
121+
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
89122
}
90123
}
91124

src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Buffers;
5+
using System.Text;
6+
using System.Text.Encodings.Web;
47
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.WebUtilities;
9+
using Microsoft.Extensions.DependencyInjection;
510

611
namespace Microsoft.AspNetCore.Components.Endpoints;
712

@@ -30,11 +35,67 @@ public virtual Task ExecuteAsync(HttpContext httpContext, RazorComponentResult r
3035
response.StatusCode = result.StatusCode.Value;
3136
}
3237

33-
return RazorComponentEndpoint.RenderComponentToResponse(
38+
return RenderComponentToResponse(
3439
httpContext,
3540
result.RenderMode,
3641
result.ComponentType,
3742
result.Parameters,
3843
result.PreventStreamingRendering);
3944
}
45+
46+
internal static Task RenderComponentToResponse(
47+
HttpContext httpContext,
48+
RenderMode renderMode,
49+
Type componentType,
50+
IReadOnlyDictionary<string, object?>? componentParameters,
51+
bool preventStreamingRendering)
52+
{
53+
var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService<EndpointHtmlRenderer>();
54+
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
55+
{
56+
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
57+
// backing buffers could come from a pool like they do during rendering.
58+
var hostParameters = ParameterView.FromDictionary(new Dictionary<string, object?>
59+
{
60+
{ nameof(RazorComponentEndpointHost.RenderMode), renderMode },
61+
{ nameof(RazorComponentEndpointHost.ComponentType), componentType },
62+
{ nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters },
63+
});
64+
65+
await using var writer = CreateResponseWriter(httpContext.Response.Body);
66+
67+
// Note that we always use Static rendering mode for the top-level output from a RazorComponentResult,
68+
// because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host
69+
// component takes care of switching into your desired render mode when it produces its own output.
70+
var htmlContent = (EndpointHtmlRenderer.PrerenderedComponentHtmlContent)(await endpointHtmlRenderer.PrerenderComponentAsync(
71+
httpContext,
72+
typeof(RazorComponentEndpointHost),
73+
RenderMode.Static,
74+
hostParameters,
75+
waitForQuiescence: preventStreamingRendering));
76+
77+
// Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context)
78+
// in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent
79+
// streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the
80+
// renderer sync context and cause a batch that would get missed.
81+
htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above
82+
83+
if (!htmlContent.QuiescenceTask.IsCompleted)
84+
{
85+
await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer);
86+
}
87+
88+
// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
89+
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
90+
// response as part of the Dispose which has a perf impact.
91+
await writer.FlushAsync();
92+
});
93+
}
94+
95+
private static TextWriter CreateResponseWriter(Stream bodyStream)
96+
{
97+
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
98+
const int DefaultBufferSize = 16 * 1024;
99+
return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
100+
}
40101
}

0 commit comments

Comments
 (0)