Skip to content

Commit 5384526

Browse files
Progressively enhanced navigation (#48899)
1 parent 32a8b7b commit 5384526

26 files changed

+758
-127
lines changed

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public Task RenderComponent()
3434
private async Task RenderComponentCore()
3535
{
3636
_context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
37+
_renderer.InitializeStreamingRenderingFraming(_context);
3738

3839
if (!await TryValidateRequestAsync(out var isPost, out var handler))
3940
{

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,33 @@ private static ValueTask<PrerenderedComponentHtmlContent> HandleNavigationExcept
147147
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
148148
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.");
149149
}
150+
else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location) && httpContext.Request.Headers.ContainsKey("blazor-enhanced-nav"))
151+
{
152+
// It's unsafe to do a 301/302/etc to an external destination when this was requested via fetch, because
153+
// assuming it doesn't expose CORS headers, we won't be allowed to follow the redirection nor will
154+
// we even find out what the destination URL would have been. But since it's our own JS code making this
155+
// fetch request, we can have a custom protocol for describing the URL we wanted to redirect to.
156+
httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location", navigationException.Location);
157+
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
158+
}
150159
else
151160
{
152161
httpContext.Response.Redirect(navigationException.Location);
153162
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
154163
}
155164
}
156165

166+
private static bool IsPossibleExternalDestination(HttpRequest request, string destinationUrl)
167+
{
168+
if (!Uri.TryCreate(destinationUrl, UriKind.Absolute, out var absoluteUri))
169+
{
170+
return false;
171+
}
172+
173+
return absoluteUri.Scheme != request.Scheme
174+
|| absoluteUri.Authority != request.Host.Value;
175+
}
176+
157177
internal static ServerComponentInvocationSequence GetOrCreateInvocationId(HttpContext httpContext)
158178
{
159179
if (!httpContext.Items.TryGetValue(ComponentSequenceKey, out var result))

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Runtime.InteropServices;
5+
using System.Text.Encodings.Web;
56
using Microsoft.AspNetCore.Components.RenderTree;
67
using Microsoft.AspNetCore.Hosting;
78
using Microsoft.AspNetCore.Http;
@@ -13,8 +14,25 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
1314

1415
internal partial class EndpointHtmlRenderer
1516
{
17+
private const string _progressivelyEnhancedNavRequestHeaderName = "blazor-enhanced-nav";
18+
private const string _streamingRenderingFramingHeaderName = "ssr-framing";
1619
private TextWriter? _streamingUpdatesWriter;
1720
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
21+
private string? _ssrFramingCommentMarkup;
22+
23+
public void InitializeStreamingRenderingFraming(HttpContext httpContext)
24+
{
25+
if (httpContext.Request.Headers.ContainsKey(_progressivelyEnhancedNavRequestHeaderName))
26+
{
27+
var id = Guid.NewGuid().ToString();
28+
httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id);
29+
_ssrFramingCommentMarkup = $"<!--{id}-->";
30+
}
31+
else
32+
{
33+
_ssrFramingCommentMarkup = string.Empty;
34+
}
35+
}
1836

1937
public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilTaskCompleted, TextWriter writer)
2038
{
@@ -26,10 +44,16 @@ public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilT
2644
throw new InvalidOperationException($"{nameof(SendStreamingUpdatesAsync)} can only be called once.");
2745
}
2846

47+
if (_ssrFramingCommentMarkup is null)
48+
{
49+
throw new InvalidOperationException("Cannot begin streaming rendering because no framing header was set.");
50+
}
51+
2952
_streamingUpdatesWriter = writer;
3053

3154
try
3255
{
56+
await writer.WriteAsync(_ssrFramingCommentMarkup);
3357
await writer.FlushAsync(); // Make sure the initial HTML was sent
3458
await untilTaskCompleted;
3559
}
@@ -39,10 +63,12 @@ public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilT
3963
}
4064
catch (Exception ex)
4165
{
66+
// Theoretically it might be possible to let the error middleware run, capture the output,
67+
// then emit it in a special format so the JS code can display the error page. However
68+
// for now we're not going to support that and will simply emit a message.
4269
HandleExceptionAfterResponseStarted(_httpContext, writer, ex);
43-
44-
// The rest of the pipeline can treat this as a regular unhandled exception
45-
// TODO: Is this really right? I think we'll terminate the response in an invalid way.
70+
await writer.FlushAsync(); // Important otherwise the client won't receive the error message, as we're about to fail the pipeline
71+
await _httpContext.Response.CompleteAsync();
4672
throw;
4773
}
4874
}
@@ -115,6 +141,7 @@ private void SendBatchAsStreamingUpdate(in RenderBatch renderBatch, TextWriter w
115141
}
116142

117143
writer.Write("</blazor-ssr>");
144+
writer.Write(_ssrFramingCommentMarkup);
118145
}
119146
}
120147

@@ -143,16 +170,16 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,
143170
? exception.ToString()
144171
: "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'";
145172

146-
writer.Write("<template blazor-type=\"exception\">");
147-
writer.Write(message);
148-
writer.Write("</template>");
173+
writer.Write("<blazor-ssr><template type=\"error\">");
174+
writer.Write(HtmlEncoder.Default.Encode(message));
175+
writer.Write("</template></blazor-ssr>");
149176
}
150177

151178
private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl)
152179
{
153-
writer.Write("<template blazor-type=\"redirection\">");
154-
writer.Write(destinationUrl);
155-
writer.Write("</template>");
180+
writer.Write("<blazor-ssr><template type=\"redirection\">");
181+
writer.Write(HtmlEncoder.Default.Encode(destinationUrl));
182+
writer.Write("</template></blazor-ssr>");
156183
}
157184

158185
protected override void WriteComponentHtml(int componentId, TextWriter output)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ internal static Task RenderComponentToResponse(
5151
var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService<EndpointHtmlRenderer>();
5252
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
5353
{
54+
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext);
55+
5456
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
5557
// backing buffers could come from a pool like they do during rendering.
5658
var hostParameters = ParameterView.FromDictionary(new Dictionary<string, object?>

src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ await RazorComponentResultExecutor.RenderComponentToResponse(
224224

225225
// Assert
226226
Assert.Equal(
227-
$"<!--bl:X-->Some output\n<!--/bl:X--><template blazor-type=\"redirection\">https://test/somewhere/else</template>",
227+
$"<!--bl:X-->Some output\n<!--/bl:X--><blazor-ssr><template type=\"redirection\">https://test/somewhere/else</template></blazor-ssr>",
228228
MaskComponentIds(GetStringContent(responseBody)));
229229
}
230230

@@ -269,18 +269,18 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOn_Emits
269269
httpContext.Response.Body = responseBody;
270270

271271
var expectedResponseExceptionInfo = isDevelopmentEnvironment
272-
? "System.InvalidTimeZoneException: Test message"
273-
: "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'";
272+
? "System.InvalidTimeZoneException: Test message with &lt;b&gt;markup&lt;/b&gt;"
273+
: "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting &#x27;DetailedErrors: true&#x27; in &#x27;appSettings.Development.json&#x27;";
274274

275275
// Act
276276
var ex = await Assert.ThrowsAsync<InvalidTimeZoneException>(() => RazorComponentResultExecutor.RenderComponentToResponse(
277277
httpContext, typeof(StreamingComponentThatThrowsAsynchronously),
278278
null, preventStreamingRendering: false));
279279

280280
// Assert
281-
Assert.Contains("Test message", ex.Message);
281+
Assert.Contains("Test message with <b>markup</b>", ex.Message);
282282
Assert.Contains(
283-
$"<!--bl:X-->Some output\n<!--/bl:X--><template blazor-type=\"exception\">{expectedResponseExceptionInfo}",
283+
$"<!--bl:X-->Some output\n<!--/bl:X--><blazor-ssr><template type=\"error\">{expectedResponseExceptionInfo}",
284284
MaskComponentIds(GetStringContent(responseBody)));
285285
}
286286

src/Components/Endpoints/test/TestComponents/StreamingComponentThatThrowsAsynchronously.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ Some output
44
protected override async Task OnInitializedAsync()
55
{
66
await Task.Yield();
7-
throw new InvalidTimeZoneException("Test message");
7+
throw new InvalidTimeZoneException("Test message with <b>markup</b>");
88
}
99
}

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.web.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Boot.Web.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,49 @@ import { shouldAutoStart } from './BootCommon';
1515
import { Blazor } from './GlobalExports';
1616
import { WebStartOptions } from './Platform/WebStartOptions';
1717
import { attachStreamingRenderingListener } from './Rendering/StreamingRendering';
18+
import { attachProgressivelyEnhancedNavigationListener, detachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
1819
import { WebAssemblyComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
1920
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
2021

2122
let started = false;
23+
let webStartOptions: Partial<WebStartOptions> | undefined;
2224

2325
async function boot(options?: Partial<WebStartOptions>): Promise<void> {
2426
if (started) {
2527
throw new Error('Blazor has already started.');
2628
}
2729
started = true;
28-
await activateInteractiveComponents(options);
30+
webStartOptions = options;
2931

3032
attachStreamingRenderingListener(options?.ssr);
33+
34+
if (!options?.ssr?.disableDomPreservation) {
35+
attachProgressivelyEnhancedNavigationListener(activateInteractiveComponents);
36+
}
37+
38+
await activateInteractiveComponents();
3139
}
3240

33-
async function activateInteractiveComponents(options?: Partial<WebStartOptions>) {
41+
async function activateInteractiveComponents() {
3442
const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
3543
const webAssemblyComponents = discoverComponents(document, 'webassembly') as WebAssemblyComponentDescriptor[];
3644

3745
if (serverComponents.length) {
38-
await startCircuit(options?.circuit, serverComponents);
46+
// TEMPORARY until https://github.com/dotnet/aspnetcore/issues/48763 is implemented
47+
// As soon we we see you have interactive components, we'll stop doing enhanced nav even if you don't have an interactive router
48+
// This is because, otherwise, we would need a way to add new interactive root components to an existing circuit and that's #48763
49+
detachProgressivelyEnhancedNavigationListener();
50+
51+
await startCircuit(webStartOptions?.circuit, serverComponents);
3952
}
4053

4154
if (webAssemblyComponents.length) {
42-
await startWebAssembly(options?.webAssembly, webAssemblyComponents);
55+
// TEMPORARY until https://github.com/dotnet/aspnetcore/issues/48763 is implemented
56+
// As soon we we see you have interactive components, we'll stop doing enhanced nav even if you don't have an interactive router
57+
// This is because, otherwise, we would need a way to add new interactive root components to an existing WebAssembly runtime and that's #48763
58+
detachProgressivelyEnhancedNavigationListener();
59+
60+
await startWebAssembly(webStartOptions?.webAssembly, webAssemblyComponents);
4361
}
4462
}
4563

src/Components/Web.JS/src/Rendering/DomMerging/AttributeSync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function synchronizeAttributes(destination: Element, source: Element) {
99

1010
// Optimize for the common case where all attributes are unchanged and are even still in the same order
1111
const destAttrsLength = destAttrs.length;
12-
if (destAttrsLength === destAttrs.length) {
12+
if (destAttrsLength === sourceAttrs.length) {
1313
let hasDifference = false;
1414
for (let i = 0; i < destAttrsLength; i++) {
1515
const sourceAttr = sourceAttrs.item(i)!;

src/Components/Web.JS/src/Rendering/DomMerging/DomSync.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import { applyAnyDeferredValue } from '../DomSpecialPropertyUtil';
55
import { synchronizeAttributes } from './AttributeSync';
66
import { UpdateCost, ItemList, Operation, computeEditScript } from './EditScript';
77

8-
export function synchronizeDomContent(destination: CommentBoundedRange | Element, newContent: DocumentFragment | Element) {
8+
export function synchronizeDomContent(destination: CommentBoundedRange | Node, newContent: Node) {
99
let destinationParent: Node;
1010
let nextDestinationNode: Node | null;
1111
let originalNodesForDiff: ItemList<Node>;
1212

1313
// Figure out how to interpret the 'destination' parameter, since it can come in two very different forms
14-
if (destination instanceof Element) {
14+
if (destination instanceof Node) {
1515
destinationParent = destination;
1616
nextDestinationNode = destination.firstChild;
1717
originalNodesForDiff = destination.childNodes;
@@ -70,7 +70,7 @@ export function synchronizeDomContent(destination: CommentBoundedRange | Element
7070

7171
// Handle any common trailing items
7272
// These can only exist if there were some edits, otherwise everything would be in the set of common leading items
73-
const endAtNodeExclOrNull = destination instanceof Element ? null : destination.endExclusive;
73+
const endAtNodeExclOrNull = destination instanceof Node ? null : destination.endExclusive;
7474
while (nextDestinationNode !== endAtNodeExclOrNull) {
7575
treatAsMatch(nextDestinationNode!, nextNewContentNode!);
7676
nextDestinationNode = nextDestinationNode!.nextSibling;
@@ -94,6 +94,9 @@ function treatAsMatch(destination: Node, source: Node) {
9494
applyAnyDeferredValue(destination as Element);
9595
synchronizeDomContent(destination as Element, source as Element);
9696
break;
97+
case Node.DOCUMENT_TYPE_NODE:
98+
// See comment below about doctype nodes. We leave them alone.
99+
break;
97100
default:
98101
throw new Error(`Not implemented: matching nodes of type ${destination.nodeType}`);
99102
}
@@ -130,6 +133,10 @@ function domNodeComparer(a: Node, b: Node): UpdateCost {
130133
// For the converse (forcing retention, even if that means reordering), we could post-process the list of
131134
// inserts/deletes to find matches based on key to treat those pairs as 'move' operations.
132135
return (a as Element).tagName === (b as Element).tagName ? UpdateCost.None : UpdateCost.Infinite;
136+
case Node.DOCUMENT_TYPE_NODE:
137+
// It's invalid to insert or delete doctype, and we have no use case for doing that. So just skip such
138+
// nodes by saying they are always unchanged.
139+
return UpdateCost.None;
133140
default:
134141
// For anything else we know nothing, so the risk-averse choice is to say we can't retain or update the old value
135142
return UpdateCost.Infinite;
@@ -169,4 +176,3 @@ class SiblingSubsetNodeList implements ItemList<Node> {
169176
this.length = this.endIndexExcl - this.startIndex;
170177
}
171178
}
172-

src/Components/Web.JS/src/Rendering/StreamingRendering.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
import { SsrStartOptions } from "../Platform/SsrStartOptions";
5+
import { performEnhancedPageLoad } from "../Services/NavigationEnhancement";
6+
import { isWithinBaseUriSpace } from "../Services/NavigationUtils";
57
import { synchronizeDomContent } from "./DomMerging/DomSync";
68

79
let enableDomPreservation = true;
@@ -34,6 +36,28 @@ class BlazorStreamingUpdate extends HTMLElement {
3436
const componentId = node.getAttribute('blazor-component-id');
3537
if (componentId) {
3638
insertStreamingContentIntoDocument(componentId, node.content);
39+
} else {
40+
switch (node.getAttribute('type')) {
41+
case 'redirection':
42+
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
43+
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
44+
const destinationUrl = node.content.textContent!;
45+
if (isWithinBaseUriSpace(destinationUrl)) {
46+
history.replaceState(null, '', destinationUrl);
47+
performEnhancedPageLoad(destinationUrl);
48+
} else {
49+
location.replace(destinationUrl);
50+
}
51+
break;
52+
case 'error':
53+
// This is kind of brutal but matches what happens without progressive enhancement
54+
document.documentElement.textContent = node.content.textContent;
55+
const docStyle = document.documentElement.style;
56+
docStyle.fontFamily = 'consolas, monospace';
57+
docStyle.whiteSpace = 'pre-wrap';
58+
docStyle.padding = '1rem';
59+
break;
60+
}
3761
}
3862
}
3963
});

0 commit comments

Comments
 (0)