Skip to content

[release/8.0] [Blazor] Improvements to SSR component activation #50848

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 43 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b565785
Dispose+re-init SSR'd components if params change
MackinnonBuck Sep 20, 2023
4fe97de
Enforce component disposal + account for `@key`
MackinnonBuck Sep 22, 2023
a04974b
Improved `@key` handling, shared more code
MackinnonBuck Sep 22, 2023
5f9bc49
Max root component count limit
MackinnonBuck Sep 24, 2023
c4349b2
No .NET->JS web root component sync anymore
MackinnonBuck Sep 24, 2023
80cb7f4
Avoid flicker when replacing component instance
MackinnonBuck Sep 25, 2023
7f775ce
Update InteractivityTest.cs
MackinnonBuck Sep 25, 2023
a8f7bfc
Fix build issues
MackinnonBuck Sep 25, 2023
27435b4
Small improvement
MackinnonBuck Sep 25, 2023
8d0108c
Compare serialized parameter values
MackinnonBuck Sep 25, 2023
6276e2d
Cleanup
MackinnonBuck Sep 25, 2023
acb5321
More cleanup
MackinnonBuck Sep 25, 2023
7de7461
Create BoundaryMarkerKeyTest.cs
MackinnonBuck Sep 25, 2023
ab97bea
WebRootComponentParameters tests
MackinnonBuck Sep 26, 2023
2dbcc38
More interactivity tests
MackinnonBuck Sep 26, 2023
44713bd
WebRootComponentManager tests
MackinnonBuck Sep 26, 2023
8d135d2
PR feedback
MackinnonBuck Sep 27, 2023
89d0506
Some cleanup
MackinnonBuck Sep 27, 2023
f59b538
Update blazor.web.js
MackinnonBuck Sep 27, 2023
366feba
More tests
MackinnonBuck Sep 27, 2023
6f2efed
Update WebAssemblyRenderer.cs
MackinnonBuck Sep 27, 2023
aed4552
Dispose+re-init SSR'd components if params change
MackinnonBuck Sep 20, 2023
20bc964
Enforce component disposal + account for `@key`
MackinnonBuck Sep 22, 2023
1b99064
Improved `@key` handling, shared more code
MackinnonBuck Sep 22, 2023
5cd6913
Max root component count limit
MackinnonBuck Sep 24, 2023
208190f
No .NET->JS web root component sync anymore
MackinnonBuck Sep 24, 2023
3b90980
Avoid flicker when replacing component instance
MackinnonBuck Sep 25, 2023
8e13336
Update InteractivityTest.cs
MackinnonBuck Sep 25, 2023
cd690f2
Fix build issues
MackinnonBuck Sep 25, 2023
1814a87
Small improvement
MackinnonBuck Sep 25, 2023
a147371
Compare serialized parameter values
MackinnonBuck Sep 25, 2023
4d605a9
Cleanup
MackinnonBuck Sep 25, 2023
419d753
More cleanup
MackinnonBuck Sep 25, 2023
556537e
Create BoundaryMarkerKeyTest.cs
MackinnonBuck Sep 25, 2023
ecb9eac
WebRootComponentParameters tests
MackinnonBuck Sep 26, 2023
2adac55
More interactivity tests
MackinnonBuck Sep 26, 2023
5e5402e
WebRootComponentManager tests
MackinnonBuck Sep 26, 2023
4b1c210
PR feedback
MackinnonBuck Sep 27, 2023
1faa9ec
Some cleanup
MackinnonBuck Sep 27, 2023
8586a31
More tests
MackinnonBuck Sep 27, 2023
a0f8a3b
Update WebAssemblyRenderer.cs
MackinnonBuck Sep 27, 2023
ca1388f
Update blazor.*.js
MackinnonBuck Sep 28, 2023
dddde99
PR feedback
MackinnonBuck Sep 29, 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,21 +18,23 @@ public ServerComponentSerializer(IDataProtectionProvider dataProtectionProvider)

public void SerializeInvocation(ref ComponentMarker marker, ServerComponentInvocationSequence invocationId, Type type, ParameterView parameters)
{
var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters);
var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters, marker.Key);
marker.WriteServerData(sequence, serverComponent);
}

private (int sequence, string payload) CreateSerializedServerComponent(
ServerComponentInvocationSequence invocationId,
Type rootComponent,
ParameterView parameters)
ParameterView parameters,
ComponentMarkerKey? key)
{
var sequence = invocationId.Next();

var (definitions, values) = ComponentParameter.FromParameterView(parameters);

var serverComponent = new ServerComponent(
sequence,
key,
rootComponent.Assembly.GetName().Name ?? throw new InvalidOperationException("Cannot prerender components from assemblies with a null name"),
rootComponent.FullName ?? throw new InvalidOperationException("Cannot prerender component types with a null name"),
Comment on lines 35 to 39
Copy link
Member Author

Choose a reason for hiding this comment

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

The key was added to ServerComponent because it needs to be part of the data protected payload now.

definitions,
Expand Down
19 changes: 14 additions & 5 deletions src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ internal class SSRRenderModeBoundary : IComponent
private readonly bool _prerender;
private RenderHandle _renderHandle;
private IReadOnlyDictionary<string, object?>? _latestParameters;
private string? _markerKey;
private ComponentMarkerKey? _markerKey;

public IComponentRenderMode RenderMode { get; }

Expand Down Expand Up @@ -154,11 +154,11 @@ private void Prerender(RenderTreeBuilder builder)
builder.CloseComponent();
}

public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? key)
public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? componentKey)
{
// 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);
_markerKey ??= GenerateMarkerKey(sequence, componentKey);

var parameters = _latestParameters is null
? ParameterView.Empty
Expand Down Expand Up @@ -190,10 +190,19 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? k
return marker;
}

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

var locationHash = $"{componentTypeNameHash}:{sequenceString}";
var formattedComponentKey = (componentKey as IFormattable)?.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty;

return new()
{
LocationHash = locationHash,
FormattedComponentKey = formattedComponentKey,
};
}

private static string ComputeComponentTypeNameHash(Type componentType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<ItemGroup>
<Compile Include="..\..\Shared\test\AutoRenderComponent.cs" Link="TestComponents\AutoRenderComponent.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\WebRootComponentParameters.cs" Link="Shared\WebRootComponentParameters.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
154 changes: 154 additions & 0 deletions src/Components/Endpoints/test/WebRootComponentParametersTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;

namespace Microsoft.AspNetCore.Components.Endpoints;

public class WebRootComponentParametersTest
{
[Fact]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterCount()
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = 123 });
var parameters2 = CreateParameters(new() { ["First"] = 123, ["Second"] = "abc" });

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.False(result);
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterNames()
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = 123 });
var parameters2 = CreateParameters(new() { ["Second"] = 123 });

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.False(result);
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterValues()
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = 123 });
var parameters2 = CreateParameters(new() { ["First"] = 456 });

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.False(result);
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterTypes()
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = 123 });
var parameters2 = CreateParameters(new() { ["First"] = 123L });

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.False(result);
}

public static readonly object[][] DefinitelyEqualParameterValues =
[
[123],
["abc"],
[new { First = 123, Second = "abc" }],
];

[Theory]
[MemberData(nameof(DefinitelyEqualParameterValues))]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsTrue_ForSameParameterValues(object value)
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = value });
var parameters2 = CreateParameters(new() { ["First"] = value });

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.True(result);
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_ReturnsTrue_ForEmptySetOfParameters()
{
// Arrange
var parameters1 = CreateParameters(new());
var parameters2 = CreateParameters(new());

// Act
var result = parameters1.DefinitelyEquals(parameters2);

// Assert
Assert.True(result);
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingNonJsonElementParameterToJsonElement()
{
// Arrange
var parameters1 = CreateParametersWithNonJsonElements(new() { ["First"] = 123 });
var parameters2 = CreateParameters(new() { ["First"] = 456 });

// Act/assert
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingJsonElementParameterToNonJsonElement()
{
// Arrange
var parameters1 = CreateParameters(new() { ["First"] = 123 });
var parameters2 = CreateParametersWithNonJsonElements(new() { ["First"] = 456 });

// Act/assert
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
}

[Fact]
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingNonJsonElementParameters()
{
// Arrange
var parameters1 = CreateParametersWithNonJsonElements(new() { ["First"] = 123 });
var parameters2 = CreateParametersWithNonJsonElements(new() { ["First"] = 456 });

// Act/assert
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
}

private static WebRootComponentParameters CreateParameters(Dictionary<string, object> parameters)
{
var parameterView = ParameterView.FromDictionary(parameters);
var (parameterDefinitions, parameterValues) = ComponentParameter.FromParameterView(parameterView);
for (var i = 0; i < parameterValues.Count; i++)
{
// WebRootComponentParameters expects parameter values to be JsonElements.
var jsonElement = JsonSerializer.SerializeToElement(parameterValues[i]);
parameterValues[i] = jsonElement;
}
return new(parameterView, parameterDefinitions.AsReadOnly(), parameterValues.AsReadOnly());
}

private static WebRootComponentParameters CreateParametersWithNonJsonElements(Dictionary<string, object> parameters)
{
var parameterView = ParameterView.FromDictionary(parameters);
var (parameterDefinitions, parameterValues) = ComponentParameter.FromParameterView(parameterView);
return new(parameterView, parameterDefinitions.AsReadOnly(), parameterValues.AsReadOnly());
}
}
31 changes: 16 additions & 15 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ private async Task TryNotifyClientErrorAsync(IClientProxy client, string error,
}

internal Task UpdateRootComponents(
(RootComponentOperation, ComponentDescriptor?)[] operations,
CircuitRootComponentOperation[] operations,
ProtectedPrerenderComponentApplicationStore store,
IServerComponentDeserializer serverComponentDeserializer,
CancellationToken cancellation)
Expand All @@ -735,6 +735,7 @@ internal Task UpdateRootComponents(

return Renderer.Dispatcher.InvokeAsync(async () =>
{
var webRootComponentManager = Renderer.GetOrCreateWebRootComponentManager();
var shouldClearStore = false;
Task[]? pendingTasks = null;
try
Expand Down Expand Up @@ -766,7 +767,7 @@ internal Task UpdateRootComponents(
for (var i = 0; i < operations.Length; i++)
{
var operation = operations[i];
if (operation.Item1.Type != RootComponentOperationType.Add)
if (operation.Type != RootComponentOperationType.Add)
{
throw new InvalidOperationException($"The first set of update operations must always be of type {nameof(RootComponentOperationType.Add)}");
}
Expand All @@ -775,32 +776,32 @@ internal Task UpdateRootComponents(
pendingTasks = new Task[operations.Length];
}

for (var i = 0; i < operations.Length;i++)
for (var i = 0; i < operations.Length; i++)
{
var (operation, descriptor) = operations[i];
var operation = operations[i];
switch (operation.Type)
{
case RootComponentOperationType.Add:
var task = Renderer.AddComponentAsync(descriptor.ComponentType, descriptor.Parameters, operation.SelectorId.Value.ToString(CultureInfo.InvariantCulture));
var task = webRootComponentManager.AddRootComponentAsync(
operation.SsrComponentId,
operation.Descriptor.ComponentType,
operation.Descriptor.Key,
operation.Descriptor.Parameters);
if (pendingTasks != null)
{
pendingTasks[i] = task;
}
break;
case RootComponentOperationType.Update:
var componentType = Renderer.GetExistingComponentType(operation.ComponentId.Value);
if (descriptor.ComponentType != componentType)
{
Log.InvalidComponentTypeForUpdate(_logger, message: "Component type mismatch.");
throw new InvalidOperationException($"Incorrect type for descriptor '{descriptor.ComponentType.FullName}'");
}

// We don't need to await component updates as any unhandled exception will be reported and terminate the circuit.
_ = Renderer.UpdateRootComponentAsync(operation.ComponentId.Value, descriptor.Parameters);

_ = webRootComponentManager.UpdateRootComponentAsync(
operation.SsrComponentId,
operation.Descriptor.ComponentType,
operation.Descriptor.Key,
operation.Descriptor.Parameters);
break;
case RootComponentOperationType.Remove:
Renderer.RemoveExistingRootComponent(operation.ComponentId.Value);
webRootComponentManager.RemoveRootComponent(operation.SsrComponentId);
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Server;

internal sealed class CircuitRootComponentOperation(RootComponentOperation operation, WebRootComponentDescriptor? descriptor = null)
{
public RootComponentOperationType Type => operation.Type;

public int SsrComponentId => operation.SsrComponentId;

public WebRootComponentDescriptor? Descriptor => descriptor;
}
Comment on lines +6 to +13
Copy link
Member Author

Choose a reason for hiding this comment

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

For some background behind the reason I added this type: The way we deserialize component descriptors in Blazor Server is:

  • We have a ComponentMarker type, which contains some non-data-protected information about an SSR'd component in addition to a data-protected payload.
  • The data-protected payload from ComponentMarker gets deserialized into a ServerComponent struct.
  • Data from the ServerComponent gets upgraded to more useful information (e.g., we discover the component type and deserialize parameter values), and we put that information into ComponentDescriptor, along with other information from the ServerComponent we need post-deserialization.

With the changes in this PR, Blazor Web root components need information that wasn't in ComponentDescriptor before:

  • The Key
  • The serialized parameter values

...and they don't rely on the Sequence number being round-tripped back to the client (the order the component was initially rendered)

I created this type (and M.A.C.Server.WebRootComponentDescriptor) so that the data/work we do to instantiate root components isn't a union of the work required by both Blazor Web and pure Blazor Server scenarios. It also makes it clearer which data is intended for which scenario.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,5 @@ internal interface IServerComponentDeserializer
bool TryDeserializeComponentDescriptorCollection(
string serializedComponentRecords,
out List<ComponentDescriptor> descriptors);
bool TryDeserializeSingleComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor? result);
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 method didn't need to be on this interface

bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor?)[] operationsWithDescriptors);
bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out CircuitRootComponentOperation[]? operationsWithDescriptors);
}
6 changes: 0 additions & 6 deletions src/Components/Server/src/Circuits/RemoteRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,6 @@ protected override void AttachRootComponentToBrowser(int componentId, string dom
_ = CaptureAsyncExceptions(attachComponentTask);
}

internal Task UpdateRootComponentAsync(int componentId, ParameterView initialParameters) =>
RenderRootComponentAsync(componentId, initialParameters);

internal void RemoveExistingRootComponent(int componentId) =>
RemoveRootComponent(componentId);

internal Type GetExistingComponentType(int componentId) =>
GetComponentState(componentId).Component.GetType();

Expand Down
Loading