Skip to content

Commit ef0da55

Browse files
authored
[Blazor] Fix Blazor root component state persistence across render modes (#62370)
* Adds a new virtual method to get the key in ComponentState. * Ignores the parent component for key computation purposes when crossing a render boundary. * Uses the ComponentMarkerKey as the key on root components. * Overrides GetComponentKey on WebAssembly and InteractiveServer to return the ComponentMarkerKey for root components. Fixes #62331
1 parent 47f3830 commit ef0da55

File tree

14 files changed

+357
-24
lines changed

14 files changed

+357
-24
lines changed

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponen
1818
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
1919
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2020
static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
21+
virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object?

src/Components/Components/src/Rendering/ComponentState.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,36 @@ internal ValueTask DisposeInBatchAsync(RenderBatchBuilder batchBuilder)
339339
return DisposeAsync();
340340
}
341341

342+
/// <summary>
343+
/// Gets the component key for this component instance.
344+
/// This is used for state persistence and component identification across render modes.
345+
/// </summary>
346+
/// <returns>The component key, or null if no key is available.</returns>
347+
protected internal virtual object? GetComponentKey()
348+
{
349+
if (ParentComponentState is not { } parentComponentState)
350+
{
351+
return null;
352+
}
353+
354+
// Check if the parentComponentState has a `@key` directive applied to the current component.
355+
var frames = parentComponentState.CurrentRenderTree.GetFrames();
356+
for (var i = 0; i < frames.Count; i++)
357+
{
358+
ref var currentFrame = ref frames.Array[i];
359+
if (currentFrame.FrameType != RenderTreeFrameType.Component ||
360+
!ReferenceEquals(Component, currentFrame.Component))
361+
{
362+
// Skip any frame that is not the current component.
363+
continue;
364+
}
365+
366+
return currentFrame.ComponentKey;
367+
}
368+
369+
return null;
370+
}
371+
342372
private string GetDebuggerDisplay()
343373
{
344374
return $"ComponentId = {ComponentId}, Type = {Component.GetType().Name}, Disposed = {_componentWasDisposed}";

src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo)
4545
return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null;
4646
}
4747

48-
[UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "OpenComponent already has the right set of attributes")] [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")]
48+
[UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")]
49+
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "OpenComponent already has the right set of attributes")]
50+
[UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")]
4951
public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
5052
{
5153
var propertyName = parameterInfo.PropertyName;
@@ -221,35 +223,46 @@ private static void GrowBuffer(ref byte[]? pool, ref Span<byte> keyBuffer, int?
221223

222224
private static object? GetSerializableKey(ComponentState componentState)
223225
{
224-
if (componentState.ParentComponentState is not { } parentComponentState)
226+
var componentKey = componentState.GetComponentKey();
227+
if (componentKey != null && IsSerializableKey(componentKey))
225228
{
226-
return null;
229+
return componentKey;
227230
}
228231

229-
// Check if the parentComponentState has a `@key` directive applied to the current component.
230-
var frames = parentComponentState.CurrentRenderTree.GetFrames();
231-
for (var i = 0; i < frames.Count; i++)
232+
return null;
233+
}
234+
235+
private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!;
236+
237+
private static string GetParentComponentType(ComponentState componentState)
238+
{
239+
if (componentState.ParentComponentState == null)
240+
{
241+
return "";
242+
}
243+
if (componentState.ParentComponentState.Component == null)
232244
{
233-
ref var currentFrame = ref frames.Array[i];
234-
if (currentFrame.FrameType != RenderTree.RenderTreeFrameType.Component ||
235-
!ReferenceEquals(componentState.Component, currentFrame.Component))
245+
return "";
246+
}
247+
248+
if (componentState.ParentComponentState.ParentComponentState != null)
249+
{
250+
var renderer = componentState.Renderer;
251+
var parentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.Component);
252+
var grandParentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.ParentComponentState.Component);
253+
if (parentRenderMode != grandParentRenderMode)
236254
{
237-
// Skip any frame that is not the current component.
238-
continue;
255+
// This is the case when EndpointHtmlRenderer introduces an SSRRenderBoundary component.
256+
// We want to return "" because the SSRRenderBoundary component is not a real component
257+
// and won't appear on the component tree in the WebAssemblyRenderer and RemoteRenderer
258+
// interactive scenarios.
259+
return "";
239260
}
240-
241-
var componentKey = currentFrame.ComponentKey;
242-
return !IsSerializableKey(componentKey) ? null : componentKey;
243261
}
244262

245-
return null;
263+
return GetComponentType(componentState.ParentComponentState);
246264
}
247265

248-
private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!;
249-
250-
private static string GetParentComponentType(ComponentState componentState) =>
251-
componentState.ParentComponentState == null ? "" : GetComponentType(componentState.ParentComponentState);
252-
253266
private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) =>
254267
SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName)));
255268

src/Components/Endpoints/src/Rendering/EndpointComponentState.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
1515
internal sealed class EndpointComponentState : ComponentState
1616
{
1717
private static readonly ConcurrentDictionary<Type, StreamRenderingAttribute?> _streamRenderingAttributeByComponentType = new();
18-
18+
private readonly EndpointHtmlRenderer _renderer;
1919
public EndpointComponentState(Renderer renderer, int componentId, IComponent component, ComponentState? parentComponentState)
2020
: base(renderer, componentId, component, parentComponentState)
2121
{
22+
_renderer = (EndpointHtmlRenderer)renderer;
23+
2224
var streamRenderingAttribute = _streamRenderingAttributeByComponentType.GetOrAdd(component.GetType(),
2325
type => type.GetCustomAttribute<StreamRenderingAttribute>());
2426

@@ -35,6 +37,22 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com
3537

3638
public bool StreamRendering { get; }
3739

40+
protected override object? GetComponentKey()
41+
{
42+
if (ParentComponentState != null && ParentComponentState.Component is SSRRenderModeBoundary boundary)
43+
{
44+
var (sequence, key) = _renderer.GetSequenceAndKey(ParentComponentState);
45+
var marker = boundary.GetComponentMarkerKey(sequence, key);
46+
if (!marker.Equals(default))
47+
{
48+
return marker.Serialized();
49+
}
50+
}
51+
52+
// Fall back to the default implementation
53+
return base.GetComponentKey();
54+
}
55+
3856
/// <summary>
3957
/// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection.
4058
/// </summary>

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Diagnostics.CodeAnalysis;
66
using System.Text.Encodings.Web;
77
using Microsoft.AspNetCore.Components.Rendering;
8+
using Microsoft.AspNetCore.Components.RenderTree;
89
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
910
using Microsoft.AspNetCore.Html;
1011
using Microsoft.AspNetCore.Http;
@@ -298,6 +299,42 @@ internal static ServerComponentInvocationSequence GetOrCreateInvocationId(HttpCo
298299
return (ServerComponentInvocationSequence)result!;
299300
}
300301

302+
internal (int sequence, object? key) GetSequenceAndKey(ComponentState boundaryComponentState)
303+
{
304+
if (boundaryComponentState is null || boundaryComponentState.Component is not SSRRenderModeBoundary boundary)
305+
{
306+
throw new InvalidOperationException(
307+
"The parent component state must be an SSRRenderModeBoundary to get the sequence and key.");
308+
}
309+
310+
// The boundary is at the root (not supported, but we handle it gracefully)
311+
if (boundaryComponentState.ParentComponentState is null)
312+
{
313+
return (0, null);
314+
}
315+
316+
// Grab the parent of the boundary component. We need to find the SSRRenderModeBoundary component marker frame
317+
// within it. As when we do `@rendermode="InteractiveServer" @key="some-key" the sequence we are interested in
318+
// is the one on the SSRRenderModeBoundary component marker frame, not the one on the nested component frame.
319+
// Same for the key.
320+
var targetState = boundaryComponentState.ParentComponentState;
321+
var frames = GetCurrentRenderTreeFrames(targetState.ComponentId);
322+
for (var i = 0; i < frames.Count; i++)
323+
{
324+
ref var frame = ref frames.Array[i];
325+
if (frame.FrameType == RenderTreeFrameType.Component &&
326+
frame.Component is SSRRenderModeBoundary candidate &&
327+
ReferenceEquals(candidate, boundary))
328+
{
329+
// This is the component marker frame, so we can use its sequence and key
330+
return (frame.Sequence, frame.ComponentKey);
331+
}
332+
}
333+
334+
throw new InvalidOperationException(
335+
"The parent component state does not have a valid SSRRenderModeBoundary component marker frame.");
336+
}
337+
301338
// An implementation of IHtmlContent that holds a reference to a component until we're ready to emit it as HTML to the response.
302339
// We don't construct the actual HTML until we receive the call to WriteTo.
303340
public class PrerenderedComponentHtmlContent : IHtmlAsyncContent

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,14 @@ private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey)
220220
FormattedComponentKey = formattedComponentKey,
221221
};
222222
}
223+
224+
/// <summary>
225+
/// Gets the ComponentMarkerKey for this boundary if it has been computed.
226+
/// This is used for state persistence across render modes.
227+
/// </summary>
228+
/// <returns>The ComponentMarkerKey if available, null otherwise.</returns>
229+
internal ComponentMarkerKey GetComponentMarkerKey(int sequence, object? componentKey)
230+
{
231+
return _markerKey ??= GenerateMarkerKey(sequence, componentKey);
232+
}
223233
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 Microsoft.AspNetCore.Components.Rendering;
5+
6+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
7+
8+
/// <summary>
9+
/// Specialized ComponentState for Server/Remote rendering that supports ComponentMarkerKey for state persistence.
10+
/// </summary>
11+
internal sealed class RemoteComponentState : ComponentState
12+
{
13+
private readonly RemoteRenderer _renderer;
14+
15+
public RemoteComponentState(
16+
RemoteRenderer renderer,
17+
int componentId,
18+
IComponent component,
19+
ComponentState? parentComponentState)
20+
: base(renderer, componentId, component, parentComponentState)
21+
{
22+
_renderer = renderer;
23+
}
24+
25+
protected override object? GetComponentKey()
26+
{
27+
var markerKey = _renderer.GetMarkerKey(this);
28+
29+
// If we have a ComponentMarkerKey, return it for state persistence consistency
30+
if (markerKey != default)
31+
{
32+
return markerKey.Serialized();
33+
}
34+
35+
// Fall back to the default implementation
36+
return base.GetComponentKey();
37+
}
38+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Concurrent;
55
using System.Diagnostics.CodeAnalysis;
66
using System.Linq;
7+
using Microsoft.AspNetCore.Components.Rendering;
78
using Microsoft.AspNetCore.Components.RenderTree;
89
using Microsoft.AspNetCore.Components.Web;
910
using Microsoft.AspNetCore.SignalR;
@@ -313,6 +314,18 @@ protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessed
313314
_ => throw new NotSupportedException($"Cannot create a component of type '{componentType}' because its render mode '{renderMode}' is not supported by interactive server-side rendering."),
314315
};
315316

317+
protected override ComponentState CreateComponentState(int componentId, IComponent component, ComponentState? parentComponentState)
318+
{
319+
return new RemoteComponentState(this, componentId, component, parentComponentState);
320+
}
321+
322+
internal ComponentMarkerKey GetMarkerKey(RemoteComponentState remoteComponentState)
323+
{
324+
return remoteComponentState.ParentComponentState != null ?
325+
default :
326+
_webRootComponentManager!.GetRootComponentKey(remoteComponentState.ComponentId);
327+
}
328+
316329
private void ProcessPendingBatch(string? errorMessageOrNull, UnacknowledgedRenderBatch entry)
317330
{
318331
var elapsedTime = entry.ValueStopwatch.GetElapsedTime();

src/Components/Shared/src/WebRootComponentManager.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,26 @@ private WebRootComponent GetRequiredWebRootComponent(int ssrComponentId)
8686
#if COMPONENTS_SERVER
8787
internal IEnumerable<(int id, ComponentMarkerKey key, (Type componentType, ParameterView parameters))> GetRootComponents()
8888
{
89-
foreach (var (id, (key, type, parameters)) in _webRootComponents)
89+
foreach (var (id, (_, key, type, parameters)) in _webRootComponents)
9090
{
9191
yield return (id, key, (type, parameters));
9292
}
9393
}
94+
9495
#endif
96+
internal ComponentMarkerKey GetRootComponentKey(int componentId)
97+
{
98+
foreach (var (_, candidate) in _webRootComponents)
99+
{
100+
var(id, key, _, _) = candidate;
101+
if (id == componentId)
102+
{
103+
return key;
104+
}
105+
}
106+
107+
return default;
108+
}
95109

96110
private sealed class WebRootComponent
97111
{
@@ -135,17 +149,17 @@ private WebRootComponent(
135149
_latestParameters = initialParameters;
136150
}
137151

138-
#if COMPONENTS_SERVER
139152
public void Deconstruct(
153+
out int interactiveComponentId,
140154
out ComponentMarkerKey key,
141155
out Type componentType,
142156
out ParameterView parameters)
143157
{
158+
interactiveComponentId = _interactiveComponentId;
144159
key = _key;
145160
componentType = _componentType;
146161
parameters = _latestParameters.Parameters;
147162
}
148-
#endif
149163

150164
public Task UpdateAsync(
151165
Renderer renderer,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 Microsoft.AspNetCore.Components.Rendering;
5+
6+
namespace Microsoft.AspNetCore.Components.WebAssembly.Rendering;
7+
8+
/// <summary>
9+
/// Specialized ComponentState for WebAssembly rendering that supports ComponentMarkerKey for state persistence.
10+
/// </summary>
11+
internal sealed class WebAssemblyComponentState : ComponentState
12+
{
13+
private readonly WebAssemblyRenderer _renderer;
14+
15+
public WebAssemblyComponentState(
16+
WebAssemblyRenderer renderer,
17+
int componentId,
18+
IComponent component,
19+
ComponentState? parentComponentState)
20+
: base(renderer, componentId, component, parentComponentState)
21+
{
22+
_renderer = renderer;
23+
}
24+
25+
protected override object? GetComponentKey()
26+
{
27+
var markerKey = _renderer.GetMarkerKey(this);
28+
29+
// If we have a ComponentMarkerKey, return it for state persistence consistency
30+
if (markerKey != default)
31+
{
32+
return markerKey.Serialized();
33+
}
34+
35+
// Fall back to the default implementation
36+
return base.GetComponentKey();
37+
}
38+
}

0 commit comments

Comments
 (0)