Skip to content

Commit 7a0507f

Browse files
[release/8.0] [Blazor] Improvements to SSR component activation (#50848)
# [release/8.0] [Blazor] Improvements to SSR component activation Improves how components rendered from a Blazor endpoint get activated for interactivity. ## Description This PR makes the following improvements: 1. An update from a Blazor endpoint cannot supply new parameters to an existing component, unless the component specifies a formattable, deterministic `@key` that uniquely identifies it. 2. The number of interactive Server root components originating from a Blazor endpoint cannot exceed the configured `CircuitRootComponentOptions.MaxJSRootComponents` option at any point in time. Fixes #50849 ## Customer Impact This change helps to limit the quantity of server resources used by Blazor Server interactivity and ensure correctness when dynamically supplying component parameter updates. ## Regression? - [ ] Yes - [X] No ## Risk - [ ] High - [X] Medium - [ ] Low The change that limits the total number of interactive server root components is fairly low-risk, and the default limit of 100 interactive server root components can be increased for apps that need it. However, the change to how new parameters are supplied is going to modify the behavior of Blazor Web apps built on previous .NET 8 preview releases. Furthermore, the code changes touch some of the core functionality of Blazor's new .NET 8 features. However, apps built prior to .NET 8 should continue to work the same way without any changes in behavior. ## Verification - [X] Manual (required) - [X] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [X] N/A
1 parent b029d1f commit 7a0507f

39 files changed

+1251
-446
lines changed

src/Components/Endpoints/src/DependencyInjection/ServerComponentSerializer.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,23 @@ public ServerComponentSerializer(IDataProtectionProvider dataProtectionProvider)
1818

1919
public void SerializeInvocation(ref ComponentMarker marker, ServerComponentInvocationSequence invocationId, Type type, ParameterView parameters)
2020
{
21-
var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters);
21+
var (sequence, serverComponent) = CreateSerializedServerComponent(invocationId, type, parameters, marker.Key);
2222
marker.WriteServerData(sequence, serverComponent);
2323
}
2424

2525
private (int sequence, string payload) CreateSerializedServerComponent(
2626
ServerComponentInvocationSequence invocationId,
2727
Type rootComponent,
28-
ParameterView parameters)
28+
ParameterView parameters,
29+
ComponentMarkerKey? key)
2930
{
3031
var sequence = invocationId.Next();
3132

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

3435
var serverComponent = new ServerComponent(
3536
sequence,
37+
key,
3638
rootComponent.Assembly.GetName().Name ?? throw new InvalidOperationException("Cannot prerender components from assemblies with a null name"),
3739
rootComponent.FullName ?? throw new InvalidOperationException("Cannot prerender component types with a null name"),
3840
definitions,

src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ internal class SSRRenderModeBoundary : IComponent
2929
private readonly bool _prerender;
3030
private RenderHandle _renderHandle;
3131
private IReadOnlyDictionary<string, object?>? _latestParameters;
32-
private string? _markerKey;
32+
private ComponentMarkerKey? _markerKey;
3333

3434
public IComponentRenderMode RenderMode { get; }
3535

@@ -154,11 +154,11 @@ private void Prerender(RenderTreeBuilder builder)
154154
builder.CloseComponent();
155155
}
156156

157-
public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? key)
157+
public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? componentKey)
158158
{
159159
// We expect that the '@key' and sequence number shouldn't change for a given component instance,
160160
// so we lazily compute the marker key once.
161-
_markerKey ??= GenerateMarkerKey(sequence, key);
161+
_markerKey ??= GenerateMarkerKey(sequence, componentKey);
162162

163163
var parameters = _latestParameters is null
164164
? ParameterView.Empty
@@ -190,10 +190,19 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? k
190190
return marker;
191191
}
192192

193-
private string GenerateMarkerKey(int sequence, object? key)
193+
private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey)
194194
{
195195
var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, ComputeComponentTypeNameHash);
196-
return $"{componentTypeNameHash}:{sequence}:{(key as IFormattable)?.ToString(null, CultureInfo.InvariantCulture)}";
196+
var sequenceString = sequence.ToString(CultureInfo.InvariantCulture);
197+
198+
var locationHash = $"{componentTypeNameHash}:{sequenceString}";
199+
var formattedComponentKey = (componentKey as IFormattable)?.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty;
200+
201+
return new()
202+
{
203+
LocationHash = locationHash,
204+
FormattedComponentKey = formattedComponentKey,
205+
};
197206
}
198207

199208
private static string ComputeComponentTypeNameHash(Type componentType)

src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<ItemGroup>
99
<Compile Include="..\..\Shared\test\AutoRenderComponent.cs" Link="TestComponents\AutoRenderComponent.cs" />
10+
<Compile Include="$(ComponentsSharedSourceRoot)src\WebRootComponentParameters.cs" Link="Shared\WebRootComponentParameters.cs" />
1011
</ItemGroup>
1112

1213
<ItemGroup>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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.Text.Json;
5+
6+
namespace Microsoft.AspNetCore.Components.Endpoints;
7+
8+
public class WebRootComponentParametersTest
9+
{
10+
[Fact]
11+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterCount()
12+
{
13+
// Arrange
14+
var parameters1 = CreateParameters(new() { ["First"] = 123 });
15+
var parameters2 = CreateParameters(new() { ["First"] = 123, ["Second"] = "abc" });
16+
17+
// Act
18+
var result = parameters1.DefinitelyEquals(parameters2);
19+
20+
// Assert
21+
Assert.False(result);
22+
}
23+
24+
[Fact]
25+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterNames()
26+
{
27+
// Arrange
28+
var parameters1 = CreateParameters(new() { ["First"] = 123 });
29+
var parameters2 = CreateParameters(new() { ["Second"] = 123 });
30+
31+
// Act
32+
var result = parameters1.DefinitelyEquals(parameters2);
33+
34+
// Assert
35+
Assert.False(result);
36+
}
37+
38+
[Fact]
39+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterValues()
40+
{
41+
// Arrange
42+
var parameters1 = CreateParameters(new() { ["First"] = 123 });
43+
var parameters2 = CreateParameters(new() { ["First"] = 456 });
44+
45+
// Act
46+
var result = parameters1.DefinitelyEquals(parameters2);
47+
48+
// Assert
49+
Assert.False(result);
50+
}
51+
52+
[Fact]
53+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsFalse_ForMismatchedParameterTypes()
54+
{
55+
// Arrange
56+
var parameters1 = CreateParameters(new() { ["First"] = 123 });
57+
var parameters2 = CreateParameters(new() { ["First"] = 123L });
58+
59+
// Act
60+
var result = parameters1.DefinitelyEquals(parameters2);
61+
62+
// Assert
63+
Assert.False(result);
64+
}
65+
66+
public static readonly object[][] DefinitelyEqualParameterValues =
67+
[
68+
[123],
69+
["abc"],
70+
[new { First = 123, Second = "abc" }],
71+
];
72+
73+
[Theory]
74+
[MemberData(nameof(DefinitelyEqualParameterValues))]
75+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsTrue_ForSameParameterValues(object value)
76+
{
77+
// Arrange
78+
var parameters1 = CreateParameters(new() { ["First"] = value });
79+
var parameters2 = CreateParameters(new() { ["First"] = value });
80+
81+
// Act
82+
var result = parameters1.DefinitelyEquals(parameters2);
83+
84+
// Assert
85+
Assert.True(result);
86+
}
87+
88+
[Fact]
89+
public void WebRootComponentParameters_DefinitelyEquals_ReturnsTrue_ForEmptySetOfParameters()
90+
{
91+
// Arrange
92+
var parameters1 = CreateParameters(new());
93+
var parameters2 = CreateParameters(new());
94+
95+
// Act
96+
var result = parameters1.DefinitelyEquals(parameters2);
97+
98+
// Assert
99+
Assert.True(result);
100+
}
101+
102+
[Fact]
103+
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingNonJsonElementParameterToJsonElement()
104+
{
105+
// Arrange
106+
var parameters1 = CreateParametersWithNonJsonElements(new() { ["First"] = 123 });
107+
var parameters2 = CreateParameters(new() { ["First"] = 456 });
108+
109+
// Act/assert
110+
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
111+
}
112+
113+
[Fact]
114+
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingJsonElementParameterToNonJsonElement()
115+
{
116+
// Arrange
117+
var parameters1 = CreateParameters(new() { ["First"] = 123 });
118+
var parameters2 = CreateParametersWithNonJsonElements(new() { ["First"] = 456 });
119+
120+
// Act/assert
121+
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
122+
}
123+
124+
[Fact]
125+
public void WebRootComponentParameters_DefinitelyEquals_Throws_WhenComparingNonJsonElementParameters()
126+
{
127+
// Arrange
128+
var parameters1 = CreateParametersWithNonJsonElements(new() { ["First"] = 123 });
129+
var parameters2 = CreateParametersWithNonJsonElements(new() { ["First"] = 456 });
130+
131+
// Act/assert
132+
Assert.Throws<InvalidCastException>(() => parameters1.DefinitelyEquals(parameters2));
133+
}
134+
135+
private static WebRootComponentParameters CreateParameters(Dictionary<string, object> parameters)
136+
{
137+
var parameterView = ParameterView.FromDictionary(parameters);
138+
var (parameterDefinitions, parameterValues) = ComponentParameter.FromParameterView(parameterView);
139+
for (var i = 0; i < parameterValues.Count; i++)
140+
{
141+
// WebRootComponentParameters expects parameter values to be JsonElements.
142+
var jsonElement = JsonSerializer.SerializeToElement(parameterValues[i]);
143+
parameterValues[i] = jsonElement;
144+
}
145+
return new(parameterView, parameterDefinitions.AsReadOnly(), parameterValues.AsReadOnly());
146+
}
147+
148+
private static WebRootComponentParameters CreateParametersWithNonJsonElements(Dictionary<string, object> parameters)
149+
{
150+
var parameterView = ParameterView.FromDictionary(parameters);
151+
var (parameterDefinitions, parameterValues) = ComponentParameter.FromParameterView(parameterView);
152+
return new(parameterView, parameterDefinitions.AsReadOnly(), parameterValues.AsReadOnly());
153+
}
154+
}

src/Components/Server/src/Circuits/CircuitHost.cs

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,7 @@ private async Task TryNotifyClientErrorAsync(IClientProxy client, string error,
726726
}
727727

728728
internal Task UpdateRootComponents(
729-
(RootComponentOperation, ComponentDescriptor?)[] operations,
729+
CircuitRootComponentOperation[] operations,
730730
ProtectedPrerenderComponentApplicationStore store,
731731
IServerComponentDeserializer serverComponentDeserializer,
732732
CancellationToken cancellation)
@@ -735,6 +735,7 @@ internal Task UpdateRootComponents(
735735

736736
return Renderer.Dispatcher.InvokeAsync(async () =>
737737
{
738+
var webRootComponentManager = Renderer.GetOrCreateWebRootComponentManager();
738739
var shouldClearStore = false;
739740
Task[]? pendingTasks = null;
740741
try
@@ -766,7 +767,7 @@ internal Task UpdateRootComponents(
766767
for (var i = 0; i < operations.Length; i++)
767768
{
768769
var operation = operations[i];
769-
if (operation.Item1.Type != RootComponentOperationType.Add)
770+
if (operation.Type != RootComponentOperationType.Add)
770771
{
771772
throw new InvalidOperationException($"The first set of update operations must always be of type {nameof(RootComponentOperationType.Add)}");
772773
}
@@ -775,32 +776,32 @@ internal Task UpdateRootComponents(
775776
pendingTasks = new Task[operations.Length];
776777
}
777778

778-
for (var i = 0; i < operations.Length;i++)
779+
for (var i = 0; i < operations.Length; i++)
779780
{
780-
var (operation, descriptor) = operations[i];
781+
var operation = operations[i];
781782
switch (operation.Type)
782783
{
783784
case RootComponentOperationType.Add:
784-
var task = Renderer.AddComponentAsync(descriptor.ComponentType, descriptor.Parameters, operation.SelectorId.Value.ToString(CultureInfo.InvariantCulture));
785+
var task = webRootComponentManager.AddRootComponentAsync(
786+
operation.SsrComponentId,
787+
operation.Descriptor.ComponentType,
788+
operation.Descriptor.Key,
789+
operation.Descriptor.Parameters);
785790
if (pendingTasks != null)
786791
{
787792
pendingTasks[i] = task;
788793
}
789794
break;
790795
case RootComponentOperationType.Update:
791-
var componentType = Renderer.GetExistingComponentType(operation.ComponentId.Value);
792-
if (descriptor.ComponentType != componentType)
793-
{
794-
Log.InvalidComponentTypeForUpdate(_logger, message: "Component type mismatch.");
795-
throw new InvalidOperationException($"Incorrect type for descriptor '{descriptor.ComponentType.FullName}'");
796-
}
797-
798796
// We don't need to await component updates as any unhandled exception will be reported and terminate the circuit.
799-
_ = Renderer.UpdateRootComponentAsync(operation.ComponentId.Value, descriptor.Parameters);
800-
797+
_ = webRootComponentManager.UpdateRootComponentAsync(
798+
operation.SsrComponentId,
799+
operation.Descriptor.ComponentType,
800+
operation.Descriptor.Key,
801+
operation.Descriptor.Parameters);
801802
break;
802803
case RootComponentOperationType.Remove:
803-
Renderer.RemoveExistingRootComponent(operation.ComponentId.Value);
804+
webRootComponentManager.RemoveRootComponent(operation.SsrComponentId);
804805
break;
805806
}
806807
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
namespace Microsoft.AspNetCore.Components.Server;
5+
6+
internal sealed class CircuitRootComponentOperation(RootComponentOperation operation, WebRootComponentDescriptor? descriptor = null)
7+
{
8+
public RootComponentOperationType Type => operation.Type;
9+
10+
public int SsrComponentId => operation.SsrComponentId;
11+
12+
public WebRootComponentDescriptor? Descriptor => descriptor;
13+
}

src/Components/Server/src/Circuits/IServerComponentDeserializer.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,5 @@ internal interface IServerComponentDeserializer
1010
bool TryDeserializeComponentDescriptorCollection(
1111
string serializedComponentRecords,
1212
out List<ComponentDescriptor> descriptors);
13-
bool TryDeserializeSingleComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out ComponentDescriptor? result);
14-
bool TryDeserializeRootComponentOperations(string serializedComponentOperations, out (RootComponentOperation, ComponentDescriptor?)[] operationsWithDescriptors);
13+
bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out CircuitRootComponentOperation[]? operationsWithDescriptors);
1514
}

src/Components/Server/src/Circuits/RemoteRenderer.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,6 @@ protected override void AttachRootComponentToBrowser(int componentId, string dom
7070
_ = CaptureAsyncExceptions(attachComponentTask);
7171
}
7272

73-
internal Task UpdateRootComponentAsync(int componentId, ParameterView initialParameters) =>
74-
RenderRootComponentAsync(componentId, initialParameters);
75-
76-
internal void RemoveExistingRootComponent(int componentId) =>
77-
RemoveRootComponent(componentId);
78-
7973
internal Type GetExistingComponentType(int componentId) =>
8074
GetComponentState(componentId).Component.GetType();
8175

0 commit comments

Comments
 (0)