Skip to content

[Blazor] Improvements to the interaction between SSR and interactive rendering #49238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
acaba4d
Stream updates can remove interactive components
MackinnonBuck Jul 6, 2023
7666f56
Merge remote-tracking branch 'origin/main' into mbuck/ssr-boundary-im…
MackinnonBuck Jul 6, 2023
5a7c33f
Improved DOM syncing
MackinnonBuck Jul 7, 2023
674cf33
Working boundary syncing, WASM TODO
MackinnonBuck Jul 13, 2023
95b262f
Minor cleanup
MackinnonBuck Jul 13, 2023
2c0bfa8
WebAssembly support
MackinnonBuck Jul 14, 2023
3145735
Update EndpointHtmlRenderer.Streaming.cs
MackinnonBuck Jul 14, 2023
62d8dd4
Don't remove components from DOM
MackinnonBuck Jul 16, 2023
531d296
Support for '@key', other improvements
MackinnonBuck Jul 16, 2023
8319aca
Merge remote-tracking branch 'origin/main' into mbuck/ssr-boundary-im…
MackinnonBuck Jul 16, 2023
f9b688d
Small fixes
MackinnonBuck Jul 16, 2023
fea2dcb
Create blazor.webview.js
MackinnonBuck Jul 16, 2023
4949da5
Cleanup
MackinnonBuck Jul 16, 2023
18829c8
Undo small change
MackinnonBuck Jul 17, 2023
f8e38de
Small fixes
MackinnonBuck Jul 17, 2023
95cce82
Update SSRRenderModeBoundary.cs
MackinnonBuck Jul 17, 2023
7373384
Merge remote-tracking branch 'origin/main' into mbuck/ssr-boundary-im…
MackinnonBuck Jul 18, 2023
abe2fb4
Stability improvements
MackinnonBuck Jul 18, 2023
dadb3cc
More stability improvements
MackinnonBuck Jul 18, 2023
8bb4182
Update InteractiveStreamingRenderingComponent.razor
MackinnonBuck Jul 19, 2023
d339790
Basic tests, lots more on the way
MackinnonBuck Jul 19, 2023
582517e
Update TestCircuitHost.cs
MackinnonBuck Jul 19, 2023
b162b3b
More tests
MackinnonBuck Jul 19, 2023
b3a01fa
Improved marker key generation
MackinnonBuck Jul 19, 2023
43bb43f
PR feedback
MackinnonBuck Jul 20, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ public ServerComponentSerializer(IDataProtectionProvider dataProtectionProvider)
.CreateProtector(ServerComponentSerializationSettings.DataProtectionProviderPurpose)
.ToTimeLimitedDataProtector();

public ServerComponentMarker SerializeInvocation(ServerComponentInvocationSequence invocationId, Type type, ParameterView parameters, bool prerendered)
public ServerComponentMarker SerializeInvocation(ServerComponentInvocationSequence invocationId, Type type, ParameterView parameters, string key, bool prerendered)
{
var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters);
return prerendered ? ServerComponentMarker.Prerendered(sequence, serverComponent) : ServerComponentMarker.NonPrerendered(sequence, serverComponent);
return prerendered ? ServerComponentMarker.Prerendered(sequence, serverComponent, key) : ServerComponentMarker.NonPrerendered(sequence, serverComponent, key);
}

private (int sequence, string payload) CreateSerializedServerComponent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
// See the details of the component serialization protocol in WebAssemblyComponentDeserializer.cs on the Components solution.
internal sealed class WebAssemblyComponentSerializer
{
public static WebAssemblyComponentMarker SerializeInvocation(Type type, ParameterView parameters, bool prerendered)
public static WebAssemblyComponentMarker SerializeInvocation(Type type, ParameterView parameters, string? key, bool prerendered)
{
var assembly = type.Assembly.GetName().Name ?? throw new InvalidOperationException("Cannot prerender components from assemblies with a null name");
var typeFullName = type.FullName ?? throw new InvalidOperationException("Cannot prerender component types with a null name");
Expand All @@ -19,8 +19,8 @@ public static WebAssemblyComponentMarker SerializeInvocation(Type type, Paramete
var serializedDefinitions = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(definitions, WebAssemblyComponentSerializationSettings.JsonSerializationOptions));
var serializedValues = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(values, WebAssemblyComponentSerializationSettings.JsonSerializationOptions));

return prerendered ? WebAssemblyComponentMarker.Prerendered(assembly, typeFullName, serializedDefinitions, serializedValues) :
WebAssemblyComponentMarker.NonPrerendered(assembly, typeFullName, serializedDefinitions, serializedValues);
return prerendered ? WebAssemblyComponentMarker.Prerendered(assembly, typeFullName, serializedDefinitions, serializedValues, key) :
WebAssemblyComponentMarker.NonPrerendered(assembly, typeFullName, serializedDefinitions, serializedValues, key);
}

internal static void AppendPreamble(TextWriter writer, WebAssemblyComponentMarker record)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,14 @@ private static void HandleNavigationAfterResponseStarted(TextWriter writer, stri
protected override void WriteComponentHtml(int componentId, TextWriter output)
=> WriteComponentHtml(componentId, output, allowBoundaryMarkers: true);

private void WriteComponentHtml(int componentId, TextWriter output, bool allowBoundaryMarkers)
protected override void RenderChildComponent(TextWriter output, ref RenderTreeFrame componentFrame)
{
var componentId = componentFrame.ComponentId;
var sequenceAndKey = new SequenceAndKey(componentFrame.Sequence, componentFrame.ComponentKey);
WriteComponentHtml(componentId, output, allowBoundaryMarkers: true, sequenceAndKey);
}

private void WriteComponentHtml(int componentId, TextWriter output, bool allowBoundaryMarkers, SequenceAndKey sequenceAndKey = default)
{
_visitedComponentIdsInCurrentStreamingBatch?.Add(componentId);

Expand All @@ -198,9 +205,8 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
// It may be better to use a custom element like <blazor-component ...>[prerendered]<blazor-component>
// so it's easier for the JS code to react automatically whenever this gets inserted or updated during
// streaming SSR or progressively-enhanced navigation.

var (serverMarker, webAssemblyMarker) = componentState.Component is SSRRenderModeBoundary boundary
? boundary.ToMarkers(_httpContext)
? boundary.ToMarkers(_httpContext, sequenceAndKey.Sequence, sequenceAndKey.Key)
: default;

if (serverMarker.HasValue)
Expand Down Expand Up @@ -246,4 +252,5 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
}

private readonly record struct ComponentIdAndDepth(int ComponentId, int Depth);
private readonly record struct SequenceAndKey(int Sequence, object? Key);
}
43 changes: 40 additions & 3 deletions src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Http;
Expand All @@ -14,11 +18,14 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
/// </summary>
internal class SSRRenderModeBoundary : IComponent
{
private static readonly ConcurrentDictionary<Type, string> _componentTypeNameHashCache = new();

private readonly Type _componentType;
private readonly IComponentRenderMode _renderMode;
private readonly bool _prerender;
private RenderHandle _renderHandle;
private IReadOnlyDictionary<string, object?>? _latestParameters;
private string? _markerKey;

public SSRRenderModeBoundary(Type componentType, IComponentRenderMode renderMode)
{
Expand Down Expand Up @@ -92,8 +99,12 @@ private void Prerender(RenderTreeBuilder builder)
builder.CloseComponent();
}

public (ServerComponentMarker?, WebAssemblyComponentMarker?) ToMarkers(HttpContext httpContext)
public (ServerComponentMarker?, WebAssemblyComponentMarker?) ToMarkers(HttpContext httpContext, int sequence, object? key)
{
// We expect that the '@key' and sequence number shouldn't change for a given component instance,
// so we lazily compute the marker key once.
_markerKey ??= GenerateMarkerKey(sequence, key);

var parameters = _latestParameters is null
? ParameterView.Empty
: ParameterView.FromDictionary((IDictionary<string, object?>)_latestParameters);
Expand All @@ -106,15 +117,41 @@ private void Prerender(RenderTreeBuilder builder)
var serverComponentSerializer = httpContext.RequestServices.GetRequiredService<ServerComponentSerializer>();

var invocationId = EndpointHtmlRenderer.GetOrCreateInvocationId(httpContext);
serverMarker = serverComponentSerializer.SerializeInvocation(invocationId, _componentType, parameters, _prerender);
serverMarker = serverComponentSerializer.SerializeInvocation(invocationId, _componentType, parameters, _markerKey, _prerender);
}

WebAssemblyComponentMarker? webAssemblyMarker = null;
if (_renderMode is WebAssemblyRenderMode or AutoRenderMode)
{
webAssemblyMarker = WebAssemblyComponentSerializer.SerializeInvocation(_componentType, parameters, _prerender);
webAssemblyMarker = WebAssemblyComponentSerializer.SerializeInvocation(_componentType, parameters, _markerKey, _prerender);
}

return (serverMarker, webAssemblyMarker);
}

private string GenerateMarkerKey(int sequence, object? key)
{
var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, ComputeComponentTypeNameHash);
return $"{componentTypeNameHash}:{sequence}:{(key as IFormattable)?.ToString(null, CultureInfo.InvariantCulture)}";
}

private static string ComputeComponentTypeNameHash(Type componentType)
{
if (componentType.FullName is not { } typeName)
{
throw new InvalidOperationException($"An invalid component type was used in {nameof(SSRRenderModeBoundary)}.");
}

var typeNameLength = typeName.Length;
var typeNameBytes = typeNameLength < 1024
? stackalloc byte[typeNameLength]
: new byte[typeNameLength];

Encoding.UTF8.GetBytes(typeName, typeNameBytes);

Span<byte> typeNameHashBytes = stackalloc byte[SHA1.HashSizeInBytes];
SHA1.HashData(typeNameBytes, typeNameHashBytes);

return Convert.ToHexString(typeNameHashBytes);
}
}
2 changes: 2 additions & 0 deletions src/Components/Server/src/Circuits/CircuitFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ public async ValueTask<CircuitHost> CreateCircuitHostAsync(
var appLifetime = scope.ServiceProvider.GetRequiredService<ComponentStatePersistenceManager>();
await appLifetime.RestoreStateAsync(store);

var serverComponentDeserializer = scope.ServiceProvider.GetRequiredService<IServerComponentDeserializer>();
var jsComponentInterop = new CircuitJSComponentInterop(_options);
var renderer = new RemoteRenderer(
scope.ServiceProvider,
_loggerFactory,
_options,
client,
serverComponentDeserializer,
_loggerFactory.CreateLogger<RemoteRenderer>(),
jsRuntime,
jsComponentInterop);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Components.Server;

internal interface IServerComponentDeserializer
{
bool TryDeserializeComponentDescriptorCollection(
string serializedComponentRecords,
out List<ComponentDescriptor> descriptors);

bool TryDeserializeSingleComponentDescriptor(ServerComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor? result);
}
86 changes: 86 additions & 0 deletions src/Components/Server/src/Circuits/RemoteRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR;
Expand All @@ -21,6 +23,7 @@ internal partial class RemoteRenderer : WebRenderer

private readonly CircuitClientProxy _client;
private readonly CircuitOptions _options;
private readonly IServerComponentDeserializer _serverComponentDeserializer;
private readonly ILogger _logger;
internal readonly ConcurrentQueue<UnacknowledgedRenderBatch> _unacknowledgedRenderBatches = new ConcurrentQueue<UnacknowledgedRenderBatch>();
private long _nextRenderId = 1;
Expand All @@ -39,13 +42,15 @@ public RemoteRenderer(
ILoggerFactory loggerFactory,
CircuitOptions options,
CircuitClientProxy client,
IServerComponentDeserializer serverComponentDeserializer,
ILogger logger,
RemoteJSRuntime jsRuntime,
CircuitJSComponentInterop jsComponentInterop)
: base(serviceProvider, loggerFactory, jsRuntime.ReadJsonSerializerOptions(), jsComponentInterop)
{
_client = client;
_options = options;
_serverComponentDeserializer = serverComponentDeserializer;
_logger = logger;

ElementReferenceContext = jsRuntime.ElementReferenceContext;
Expand All @@ -59,12 +64,90 @@ public Task AddComponentAsync(Type componentType, ParameterView parameters, stri
return RenderRootComponentAsync(componentId, parameters);
}

protected override int GetWebRendererId() => (int)WebRendererId.Server;

protected override void AttachRootComponentToBrowser(int componentId, string domElementSelector)
{
var attachComponentTask = _client.SendAsync("JS.AttachComponent", componentId, domElementSelector);
_ = CaptureAsyncExceptions(attachComponentTask);
}

protected override void UpdateRootComponents(string operationsJson)
{
var operations = JsonSerializer.Deserialize<IEnumerable<RootComponentOperation<ServerComponentMarker>>>(
operationsJson,
ServerComponentSerializationSettings.JsonSerializationOptions);

foreach (var operation in operations)
{
switch (operation.Type)
{
case RootComponentOperationType.Add:
AddRootComponent(operation);
break;
case RootComponentOperationType.Update:
UpdateRootComponent(operation);
break;
case RootComponentOperationType.Remove:
RemoveRootComponent(operation);
break;
}
}

return;
Copy link
Member

@SteveSandersonMS SteveSandersonMS Jul 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return probably not needed, though unsure if you're adding this intentionally as a matter of code style ahead of local functions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was intentional to make it clear that there isn't some code hiding after the local function declarations. I can remove it if you feel that it adds more confusion than it eliminates!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can consider not putting these local functions if their "combined size" is so big that makes you lose track of what's going on in the method. They might be better off as separate members.


void AddRootComponent(RootComponentOperation<ServerComponentMarker> operation)
{
if (operation.SelectorId is not { } selectorId)
{
Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing selector ID.");
return;
}

if (!_serverComponentDeserializer.TryDeserializeSingleComponentDescriptor(operation.Marker, out var descriptor))
{
throw new InvalidOperationException("Failed to deserialize a component descriptor when adding a new root component.");
}

_ = AddComponentAsync(descriptor.ComponentType, descriptor.Parameters, selectorId.ToString(CultureInfo.InvariantCulture));
}

void UpdateRootComponent(RootComponentOperation<ServerComponentMarker> operation)
{
if (operation.ComponentId is not { } componentId)
{
Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing component ID.");
return;
}

var componentState = GetComponentState(componentId);

if (!_serverComponentDeserializer.TryDeserializeSingleComponentDescriptor(operation.Marker, out var descriptor))
{
throw new InvalidOperationException("Failed to deserialize a component descriptor when updating an existing root component.");
}

if (descriptor.ComponentType != componentState.Component.GetType())
{
Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Component type mismatch.");
return;
}

_ = RenderRootComponentAsync(componentId, descriptor.Parameters);
}

void RemoveRootComponent(RootComponentOperation<ServerComponentMarker> operation)
{
if (operation.ComponentId is not { } componentId)
{
Log.InvalidRootComponentOperation(_logger, operation.Type, message: "Missing component ID.");
return;
}

this.RemoveRootComponent(componentId);
}
}

protected override void ProcessPendingRender()
{
if (_unacknowledgedRenderBatches.Count >= _options.MaxBufferedUnacknowledgedRenderBatches)
Expand Down Expand Up @@ -388,6 +471,9 @@ public static void CompletingBatchWithoutError(ILogger logger, long batchId, Tim

[LoggerMessage(107, LogLevel.Debug, "The queue of unacknowledged render batches is full.", EventName = "FullUnacknowledgedRenderBatchesQueue")]
public static partial void FullUnacknowledgedRenderBatchesQueue(ILogger logger);

[LoggerMessage(108, LogLevel.Debug, "The root component operation of type '{OperationType}' was invalid: {Message}", EventName = "InvalidRootComponentOperation")]
public static partial void InvalidRootComponentOperation(ILogger logger, RootComponentOperationType operationType, string message);
}
}

Expand Down
Loading