diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index c243d71cf252..1860c583402f 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -3,6 +3,7 @@ abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.Dispose( abstract Microsoft.AspNetCore.Components.CascadingParameterSubscription.GetCurrentValue() -> object? Microsoft.AspNetCore.Components.CascadingParameterSubscription Microsoft.AspNetCore.Components.CascadingParameterSubscription.CascadingParameterSubscription() -> void +Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetAttributeValue(int frameIndex, object? value) -> void static Microsoft.AspNetCore.Components.NavigationManagerExtensions.GetUriWithHash(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! hash) -> string! Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.init -> void diff --git a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs index edab02398e8f..70719a227d80 100644 --- a/src/Components/Components/src/Rendering/RenderTreeBuilder.cs +++ b/src/Components/Components/src/Rendering/RenderTreeBuilder.cs @@ -777,6 +777,35 @@ internal bool InsertAttributeExpensive(int insertAtIndex, int sequence, string a public ArrayRange GetFrames() => _entries.ToRange(); + /// + /// Replaces the attribute value of an existing attribute frame at the specified index. + /// This is used to update attribute values in-place after frames have been appended, + /// for example when wrapping delegates during serialization. + /// + /// The zero-based index of the attribute frame whose value should be replaced. + /// The new attribute value. + /// Thrown when is outside the range of appended frames. + /// Thrown when the frame at is not of type . + public void SetAttributeValue(int frameIndex, object? value) + { + var frames = _entries.Buffer; + var count = _entries.Count; + + if ((uint)frameIndex >= (uint)count) + { + throw new ArgumentOutOfRangeException(nameof(frameIndex)); + } + + ref var frame = ref frames[frameIndex]; + if (frame.FrameTypeField != RenderTreeFrameType.Attribute) + { + throw new InvalidOperationException( + $"The frame at index {frameIndex} is of type '{frame.FrameTypeField}', not '{RenderTreeFrameType.Attribute}'."); + } + + frame.AttributeValueField = value; + } + internal void AssertTreeIsValid(IComponent component) { if (_openElementIndices.Count > 0) diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj index c7635911bbf4..05fd017059b2 100644 --- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj +++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj @@ -40,6 +40,9 @@ + + + diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index 1febaa8f4af5..c30fe7083925 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -5,11 +5,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -27,8 +29,10 @@ internal class SSRRenderModeBoundary : IComponent private readonly bool _prerender; private RenderHandle _renderHandle; private IReadOnlyDictionary? _latestParameters; + private Dictionary? _topLevelCaptures; private ComponentMarkerKey? _markerKey; private readonly HttpContext _httpContext; + private ILogger? _renderFragmentSerializationLogger; public IComponentRenderMode RenderMode { get; } @@ -108,6 +112,24 @@ public Task SetParametersAsync(ParameterView parameters) ValidateParameters(_latestParameters); + if (_prerender) + { + // Replace each top-level RenderFragment parameter with a capture wrapper + // so that when the component invokes the fragment during prerendering, + // the output frames are captured for later serialization. + var parametersDict = (Dictionary)_latestParameters; + foreach (var name in parametersDict.Keys.ToArray()) + { + if (parametersDict[name] is RenderFragment rf) + { + var capture = new RenderFragmentCapture(rf); + _topLevelCaptures ??= new(); + _topLevelCaptures[name] = capture; + parametersDict[name] = (RenderFragment)capture.Invoke; + } + } + } + if (RenderMode is InteractiveWebAssemblyRenderMode) { // Preload WebAssembly assets when using WebAssembly (not Auto) mode @@ -150,13 +172,10 @@ private void ValidateParameters(IReadOnlyDictionary latestParam { throw new InvalidOperationException($"Cannot pass RenderFragment parameter '{name}' to component '{_componentType.Name}' with rendermode '{RenderMode.GetType().Name}'. Templated content can't be passed across a rendermode boundary, because it is arbitrary code and cannot be serialized."); } - else + else if (value is not RenderFragment) { - // TODO: Ideally we *should* support RenderFragment (the non-generic version) by prerendering it - // However it's very nontrivial since it means we have to execute it within the current renderer - // somehow without actually emitting its result directly, wait for quiescence, and then prerender - // the output into a separate buffer so we can serialize it in a special way. - // A prototype implementation is at https://github.com/dotnet/aspnetcore/commit/ed330ff5b143974d9060828a760ad486b1d386ac + // Non-RenderFragment delegates (event handlers, etc.) can't cross render mode boundaries. + // RenderFragment is allowed and will be serialized in ToMarker(). throw new InvalidOperationException($"Cannot pass the parameter '{name}' to component '{_componentType.Name}' with rendermode '{RenderMode.GetType().Name}'. This is because the parameter is of the delegate type '{value.GetType()}', which is arbitrary code and cannot be serialized."); } } @@ -181,9 +200,14 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? c // so we lazily compute the marker key once. _markerKey ??= GenerateMarkerKey(sequence, componentKey); - var parameters = _latestParameters is null + // Build a serialization-safe copy of parameters, replacing RenderFragment delegates with DTOs + _renderFragmentSerializationLogger ??= httpContext.RequestServices + .GetRequiredService() + .CreateLogger(typeof(RenderFragmentSerializer)); + + var serializableParameters = _latestParameters is null ? ParameterView.Empty - : ParameterView.FromDictionary((IDictionary)_latestParameters); + : BuildSerializableParameterView(_latestParameters, _renderFragmentSerializationLogger); var marker = RenderMode switch { @@ -200,17 +224,48 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? c var serverComponentSerializer = httpContext.RequestServices.GetRequiredService(); var invocationId = EndpointHtmlRenderer.GetOrCreateInvocationId(httpContext); - serverComponentSerializer.SerializeInvocation(ref marker, invocationId, _componentType, parameters); + serverComponentSerializer.SerializeInvocation(ref marker, invocationId, _componentType, serializableParameters); } if (RenderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode) { - WebAssemblyComponentSerializer.SerializeInvocation(ref marker, _componentType, parameters); + WebAssemblyComponentSerializer.SerializeInvocation(ref marker, _componentType, serializableParameters); } return marker; } + private ParameterView BuildSerializableParameterView( + IReadOnlyDictionary latestParameters, + ILogger logger) + { + var dict = new Dictionary(latestParameters.Count); + foreach (var (name, value) in latestParameters) + { + if (value is RenderFragment) + { + if (_topLevelCaptures is null || !_topLevelCaptures.TryGetValue(name, out var capture)) + { + // If we didn't wrap the RenderFragment in a capture, it means prerendering is disabled. + // If the capture is null, then fragment was conditionally rendered and didn't execute. In either case we can't serialize it. + throw new InvalidOperationException( + $"Cannot serialize RenderFragment parameter '{name}' for component '{_componentType.Name}', because the RenderFragment was not executed. It can be due to disabled prerendering or conditional rendering."); + } + + dict[name] = new SerializedRenderFragment + { + Nodes = RenderFragmentSerializer.SerializeFrames(capture, logger, _componentType.Name) + }; + } + else + { + dict[name] = value; + } + } + + return ParameterView.FromDictionary(dict); + } + private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey) { var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, TypeNameHash.Compute); diff --git a/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs b/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs new file mode 100644 index 000000000000..ed10f65d2ac0 --- /dev/null +++ b/src/Components/Endpoints/test/RenderFragmentSerializerTest.cs @@ -0,0 +1,561 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Text.Json; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class RenderFragmentSerializerTest +{ + private static readonly NullLogger _logger = NullLogger.Instance; + private static readonly JsonSerializerOptions _jsonOptions = ServerComponentSerializationSettings.JsonSerializationOptions; + private static readonly ComponentParametersTypeCache _typeCache = new(); + + private static List SerializeFragment(RenderFragment fragment) + { + var capture = new RenderFragmentCapture(fragment); + using var builder = new RenderTreeBuilder(); + capture.Invoke(builder); + return RenderFragmentSerializer.SerializeFrames(capture, _logger); + } + + [Fact] + public void Serialize_EmptyFragment_ReturnsEmptyList() + { + RenderFragment fragment = builder => { }; + + var result = SerializeFragment(fragment); + + Assert.Empty(result); + } + + [Fact] + public void Serialize_TextContent_ProducesTextNode() + { + RenderFragment fragment = builder => + { + builder.AddContent(0, "Hello world"); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.Equal("text", node.Type); + Assert.Equal("Hello world", node.Content); + } + + [Fact] + public void Serialize_MarkupContent_ProducesMarkupNode() + { + RenderFragment fragment = builder => + { + builder.AddMarkupContent(0, "bold"); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.Equal("markup", node.Type); + Assert.Equal("bold", node.Content); + } + + [Fact] + public void Serialize_ElementWithAttributes_ProducesTreeNode() + { + RenderFragment fragment = builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "container"); + builder.AddAttribute(2, "id", "test"); + builder.AddContent(3, "content"); + builder.CloseElement(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.Equal("element", node.Type); + Assert.Equal("div", node.Tag); + Assert.NotNull(node.Attributes); + Assert.Equal(2, node.Attributes!.Count); + Assert.Equal("class", node.Attributes[0].Name); + Assert.Equal("container", node.Attributes[0].Value); + Assert.Equal("id", node.Attributes[1].Name); + Assert.Equal("test", node.Attributes[1].Value); + Assert.NotNull(node.Children); + var child = Assert.Single(node.Children!); + Assert.Equal("text", child.Type); + Assert.Equal("content", child.Content); + } + + [Fact] + public void Serialize_NestedElements_ProducesTreeStructure() + { + RenderFragment fragment = builder => + { + builder.OpenElement(0, "div"); + builder.OpenElement(1, "p"); + builder.AddContent(2, "text"); + builder.CloseElement(); + builder.CloseElement(); + }; + + var result = SerializeFragment(fragment); + + var div = Assert.Single(result); + Assert.Equal("element", div.Type); + Assert.Equal("div", div.Tag); + var p = Assert.Single(div.Children!); + Assert.Equal("element", p.Type); + Assert.Equal("p", p.Tag); + var text = Assert.Single(p.Children!); + Assert.Equal("text", text.Type); + Assert.Equal("text", text.Content); + } + + [Fact] + public void Serialize_Component_ProducesComponentDescriptor() + { + RenderFragment fragment = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, "Title", "Hello"); + builder.CloseComponent(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.Equal("component", node.Type); + Assert.Contains(nameof(TestComponent), node.TypeName); + Assert.NotNull(node.ComponentParameters); + var param = Assert.Single(node.ComponentParameters!); + Assert.Equal("Title", param.Name); + Assert.Equal("Hello", param.Value); + } + + [Fact] + public void Serialize_Region_IsTransparent() + { + RenderFragment fragment = builder => + { + builder.OpenRegion(0); + builder.AddContent(1, "inside region"); + builder.CloseRegion(); + }; + + var result = SerializeFragment(fragment); + + // Region is transparent — its children are inlined + var node = Assert.Single(result); + Assert.Equal("text", node.Type); + Assert.Equal("inside region", node.Content); + } + + [Fact] + public void Serialize_DelegateAttribute_IsSkipped() + { + RenderFragment fragment = builder => + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "onclick", (Action)(() => { })); + builder.AddContent(2, "Click me"); + builder.CloseElement(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.Equal("element", node.Type); + Assert.Null(node.Attributes); // onclick was skipped, no attributes remain + var child = Assert.Single(node.Children!); + Assert.Equal("text", child.Type); + Assert.Equal("Click me", child.Content); + } + + [Fact] + public void Serialize_EventCallbackAttribute_IsSkipped() + { + RenderFragment fragment = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "OnClick", EventCallback.Factory.Create(new object(), () => { })); + builder.AddComponentParameter(2, "Title", "Hello"); + builder.CloseComponent(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.DoesNotContain(node.ComponentParameters!, p => p.Name == "OnClick"); + Assert.Contains(node.ComponentParameters!, p => p.Name == "Title"); + } + + [Fact] + public void Serialize_EventCallbackOfT_Attribute_IsSkipped() + { + RenderFragment fragment = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "OnChange", EventCallback.Factory.Create(new object(), _ => { })); + builder.AddComponentParameter(2, "Title", "Hello"); + builder.CloseComponent(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.DoesNotContain(node.ComponentParameters!, p => p.Name == "OnChange"); + Assert.Contains(node.ComponentParameters!, p => p.Name == "Title"); + } + + [Fact] + public void Serialize_SkippedFrames_SiblingStructurePreserved() + { + RenderFragment fragment = builder => + { + builder.OpenElement(0, "div"); + builder.OpenElement(1, "button"); + builder.AddAttribute(2, "onclick", (Action)(() => { })); + builder.AddContent(3, "Click"); + builder.CloseElement(); + builder.OpenElement(4, "span"); + builder.AddContent(5, "sibling"); + builder.CloseElement(); + builder.CloseElement(); + }; + + var result = SerializeFragment(fragment); + + var div = Assert.Single(result); + Assert.Equal("element", div.Type); + Assert.Equal("div", div.Tag); + Assert.Equal(2, div.Children!.Count); + + var button = div.Children[0]; + Assert.Equal("element", button.Type); + Assert.Equal("button", button.Tag); + Assert.Null(button.Attributes); // onclick skipped + var buttonText = Assert.Single(button.Children!); + Assert.Equal("Click", buttonText.Content); + + var span = div.Children[1]; + Assert.Equal("element", span.Type); + Assert.Equal("span", span.Tag); + var spanText = Assert.Single(span.Children!); + Assert.Equal("sibling", spanText.Content); + } + + [Fact] + public void Serialize_BooleanAttribute_PreservesValue() + { + RenderFragment fragment = builder => + { + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "disabled", true); + builder.CloseElement(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + var attr = Assert.Single(node.Attributes!); + Assert.Equal("disabled", attr.Name); + Assert.Equal(true, attr.Value); + } + + [Fact] + public void Deserialize_EmptyList_ProducesEmptyFragment() + { + var nodes = new List(); + + var fragment = RenderFragmentSerializer.Deserialize(nodes, _jsonOptions, _typeCache); + + var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + Assert.Equal(0, frames.Count); + } + + [Fact] + public void Deserialize_TextNode_ProducesTextContent() + { + var nodes = new List + { + new() { Type = "text", Content = "Hello" } + }; + + var fragment = RenderFragmentSerializer.Deserialize(nodes, _jsonOptions, _typeCache); + + var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + Assert.Equal(1, frames.Count); + Assert.Equal(RenderTreeFrameType.Text, frames.Array[0].FrameType); + Assert.Equal("Hello", frames.Array[0].TextContent); + } + + [Fact] + public void Deserialize_ElementWithContent_ProducesCorrectTree() + { + var nodes = new List + { + new() + { + Type = "element", + Tag = "div", + Attributes = new() + { + new() { Name = "class", Value = "test" } + }, + Children = new() + { + new() { Type = "text", Content = "content" } + } + } + }; + + var fragment = RenderFragmentSerializer.Deserialize(nodes, _jsonOptions, _typeCache); + + var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + + Assert.Equal(RenderTreeFrameType.Element, frames.Array[0].FrameType); + Assert.Equal("div", frames.Array[0].ElementName); + Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType); + Assert.Equal("class", frames.Array[1].AttributeName); + Assert.Equal("test", frames.Array[1].AttributeValue); + Assert.Equal(RenderTreeFrameType.Text, frames.Array[2].FrameType); + Assert.Equal("content", frames.Array[2].TextContent); + } + + [Fact] + public void Deserialize_NestedRenderFragmentFromModel_ProducesRenderFragmentParameter() + { + var nodes = new List + { + new() + { + Type = "component", + TypeName = typeof(TestComponent).FullName, + TypeAssembly = typeof(TestComponent).Assembly.GetName().Name, + ComponentParameters = new() + { + new() + { + Name = "ChildContent", + Value = new SerializedRenderFragment + { + Nodes = new() + { + new() { Type = "text", Content = "hello from nested" } + } + }, + TypeName = RenderFragmentSerializer.SerializedRenderFragmentValueType, + } + } + } + }; + + var fragment = RenderFragmentSerializer.Deserialize(nodes, _jsonOptions, _typeCache); + + var builder = new RenderTreeBuilder(); + fragment(builder); + var frames = builder.GetFrames(); + + Assert.Equal(RenderTreeFrameType.Component, frames.Array[0].FrameType); + Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType); + Assert.Equal("ChildContent", frames.Array[1].AttributeName); + var childContent = Assert.IsType(frames.Array[1].AttributeValue); + + var innerBuilder = new RenderTreeBuilder(); + childContent(innerBuilder); + var innerFrames = innerBuilder.GetFrames(); + Assert.Equal(RenderTreeFrameType.Text, innerFrames.Array[0].FrameType); + Assert.Equal("hello from nested", innerFrames.Array[0].TextContent); + } + + [Fact] + public void Roundtrip_ComplexFragment_PreservesStructure() + { + RenderFragment original = builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "wrapper"); + builder.OpenElement(2, "p"); + builder.AddContent(3, "Hello "); + builder.OpenElement(4, "strong"); + builder.AddContent(5, "world"); + builder.CloseElement(); + builder.CloseElement(); + builder.AddMarkupContent(6, "
"); + builder.CloseElement(); + }; + + var serialized = SerializeFragment(original); + var deserialized = RenderFragmentSerializer.Deserialize(serialized, _jsonOptions, _typeCache); + + var roundtripBuilder = new RenderTreeBuilder(); + deserialized(roundtripBuilder); + var roundtripFrames = roundtripBuilder.GetFrames(); + + // Verify the tree structure is preserved + // div > (p > ("Hello " + strong > "world"),
) + Assert.Equal(RenderTreeFrameType.Element, roundtripFrames.Array[0].FrameType); + Assert.Equal("div", roundtripFrames.Array[0].ElementName); + Assert.Equal(RenderTreeFrameType.Attribute, roundtripFrames.Array[1].FrameType); + Assert.Equal("class", roundtripFrames.Array[1].AttributeName); + Assert.Equal("wrapper", roundtripFrames.Array[1].AttributeValue); + Assert.Equal(RenderTreeFrameType.Element, roundtripFrames.Array[2].FrameType); + Assert.Equal("p", roundtripFrames.Array[2].ElementName); + Assert.Equal(RenderTreeFrameType.Text, roundtripFrames.Array[3].FrameType); + Assert.Equal("Hello ", roundtripFrames.Array[3].TextContent); + Assert.Equal(RenderTreeFrameType.Element, roundtripFrames.Array[4].FrameType); + Assert.Equal("strong", roundtripFrames.Array[4].ElementName); + Assert.Equal(RenderTreeFrameType.Text, roundtripFrames.Array[5].FrameType); + Assert.Equal("world", roundtripFrames.Array[5].TextContent); + Assert.Equal(RenderTreeFrameType.Markup, roundtripFrames.Array[6].FrameType); + Assert.Equal("
", roundtripFrames.Array[6].MarkupContent); + } + + [Theory] + [InlineData(42)] + [InlineData("my-key")] + public void Roundtrip_ElementWithKey_PreservesKeyAfterJson(object key) + { + RenderFragment original = builder => + { + builder.OpenElement(0, "div"); + builder.SetKey(key); + builder.AddContent(1, "keyed"); + builder.CloseElement(); + }; + + var serialized = SerializeFragment(original); + + Assert.Equal(key, serialized[0].Key); + + var json = JsonSerializer.Serialize(serialized, _jsonOptions); + var deserialized = JsonSerializer.Deserialize>(json, _jsonOptions)!; + var fragment = RenderFragmentSerializer.Deserialize(deserialized, _jsonOptions, _typeCache); + + using var builder2 = new RenderTreeBuilder(); + fragment(builder2); + var frames = builder2.GetFrames(); + + Assert.Equal(key.GetType(), frames.Array[0].ElementKey!.GetType()); + Assert.Equal(key, frames.Array[0].ElementKey); + } + + [Fact] + public void Roundtrip_ComponentWithTypedParameters_PreservesTypesAfterJson() + { + RenderFragment original = builder => + { + builder.OpenComponent(0); + builder.SetKey(99); + builder.AddComponentParameter(1, "Count", 42); + builder.AddComponentParameter(2, "Score", 3.14); + builder.CloseComponent(); + }; + + var serialized = SerializeFragment(original); + + var json = JsonSerializer.Serialize(serialized, _jsonOptions); + var deserialized = JsonSerializer.Deserialize>(json, _jsonOptions)!; + var fragment = RenderFragmentSerializer.Deserialize(deserialized, _jsonOptions, _typeCache); + + using var builder2 = new RenderTreeBuilder(); + fragment(builder2); + var frames = builder2.GetFrames(); + + Assert.IsType(frames.Array[0].ComponentKey); + Assert.Equal(99, frames.Array[0].ComponentKey); + Assert.IsType(frames.Array[1].AttributeValue); + Assert.Equal(42, frames.Array[1].AttributeValue); + Assert.IsType(frames.Array[2].AttributeValue); + Assert.Equal(3.14, frames.Array[2].AttributeValue); + } + + [Fact] + public void Serialize_GenericRenderFragment_IsSkipped() + { + RenderFragment typedFragment = value => builder => + { + builder.AddContent(0, value); + }; + + RenderFragment fragment = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "TypedContent", typedFragment); + builder.AddComponentParameter(2, "Title", "Hello"); + builder.CloseComponent(); + }; + + var result = SerializeFragment(fragment); + + var node = Assert.Single(result); + Assert.DoesNotContain(node.ComponentParameters!, p => p.Name == "TypedContent"); + Assert.Contains(node.ComponentParameters!, p => p.Name == "Title"); + } + + [Fact] + public void Serialize_LargeFragment_HandlesCorrectly() + { + RenderFragment fragment = builder => + { + for (var i = 0; i < 200; i++) + { + builder.OpenElement(i * 2, "span"); + builder.CloseElement(); + } + }; + + var result = SerializeFragment(fragment); + + Assert.Equal(200, result.Count); + Assert.All(result, n => Assert.Equal("element", n.Type)); + + var deserialized = RenderFragmentSerializer.Deserialize(result, _jsonOptions, _typeCache); + var builder2 = new RenderTreeBuilder(); + deserialized(builder2); + var frames = builder2.GetFrames(); + Assert.Equal(200, frames.Count); + } + + private class TestComponent : IComponent + { + [Parameter] + public string? Title { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + public void Attach(RenderHandle renderHandle) { } + + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + private class TypedComponent : IComponent + { + [Parameter] + public int Count { get; set; } + + [Parameter] + public Guid Id { get; set; } + + [Parameter] + public double Score { get; set; } + + public void Attach(RenderHandle renderHandle) { } + + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } +} diff --git a/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs b/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs index f521373db15f..38caea1f5baf 100644 --- a/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs +++ b/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs @@ -3,6 +3,9 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -16,7 +19,7 @@ public class SSRRenderModeBoundaryTest public void DoesNotAssertAboutConfiguredRenderModesOnUnknownEndpoints() { // Arrange: an endpoint with no ConfiguredRenderModesMetadata - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(), null)); // Act/Assert: no exception means we're OK @@ -29,7 +32,7 @@ public void DoesNotAssertAboutConfiguredRenderModesOnUnknownEndpoints() public void ThrowsIfServerRenderModeUsedAndNotConfigured() { // Arrange - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); PrepareEndpoint(httpContext, new WebAssemblyRenderModeSubclass()); // Act/Assert @@ -43,7 +46,7 @@ public void ThrowsIfServerRenderModeUsedAndNotConfigured() public void ThrowsIfWebAssemblyRenderModeUsedAndNotConfigured() { // Arrange - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); PrepareEndpoint(httpContext, new ServerRenderModeSubclass()); // Act/Assert @@ -57,7 +60,7 @@ public void ThrowsIfWebAssemblyRenderModeUsedAndNotConfigured() public void ThrowsIfAutoRenderModeUsedAndServerNotConfigured() { // Arrange - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); PrepareEndpoint(httpContext, new WebAssemblyRenderModeSubclass()); // Act/Assert @@ -71,7 +74,7 @@ public void ThrowsIfAutoRenderModeUsedAndServerNotConfigured() public void ThrowsIfAutoRenderModeUsedAndWebAssemblyNotConfigured() { // Arrange - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); PrepareEndpoint(httpContext, new ServerRenderModeSubclass()); // Act/Assert @@ -87,6 +90,16 @@ private static void PrepareEndpoint(HttpContext httpContext, params IComponentRe new ConfiguredRenderModesMetadata(configuredModes)), null)); } + private static DefaultHttpContext CreateHttpContext() + { + return new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .BuildServiceProvider(), + }; + } + class TestComponent : IComponent { public void Attach(RenderHandle renderHandle) @@ -112,7 +125,7 @@ public static IEnumerable ComponentKeyTestData() public void GetComponentMarkerKey_WorksWithVariousKeyTypes(object componentKey, string expectedFormattedKey) { // Arrange - var httpContext = new DefaultHttpContext(); + var httpContext = CreateHttpContext(); var boundary = new SSRRenderModeBoundary(httpContext, typeof(TestComponent), new InteractiveServerRenderMode()); // Act diff --git a/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs index 918775902f8c..161346b417fe 100644 --- a/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs +++ b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs @@ -49,6 +49,23 @@ public bool TryDeserializeParameters(IList parametersDefinit Log.IncompleteParameterDefinition(_logger, definition.Name, definition.TypeName, definition.Assembly); return false; } + else if (definition.TypeName == typeof(SerializedRenderFragment).FullName + && definition.Assembly == "Microsoft.AspNetCore.Components.Endpoints") + { + try + { + var value = (JsonElement)parameterValues[i]; + var serialized = JsonSerializer.Deserialize( + value.GetRawText(), + ServerComponentSerializationSettings.JsonSerializationOptions); + parametersDictionary.Add(definition.Name, RenderFragmentSerializer.Deserialize(serialized!.Nodes, ServerComponentSerializationSettings.JsonSerializationOptions, _parametersCache)); + } + catch (Exception e) + { + Log.InvalidParameterValue(_logger, definition.Name, definition.TypeName, definition.Assembly, e); + return false; + } + } else { var parameterType = _parametersCache.GetParameterType(definition.Assembly, definition.TypeName); @@ -66,7 +83,6 @@ public bool TryDeserializeParameters(IList parametersDefinit value.GetRawText(), parameterType, ServerComponentSerializationSettings.JsonSerializationOptions); - parametersDictionary.Add(definition.Name, parameterValue); } catch (Exception e) diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 72809b5d3b3c..391b4f48a5a6 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -64,6 +64,8 @@ + + diff --git a/src/Components/Shared/src/RenderFragmentCapture.cs b/src/Components/Shared/src/RenderFragmentCapture.cs new file mode 100644 index 000000000000..2d1f6d58fbb3 --- /dev/null +++ b/src/Components/Shared/src/RenderFragmentCapture.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components; + +// Wrapper for one RenderFragment instance. Captures the frames produced by the original +// fragment, and tracks any nested RenderFragment parameters discovered inside those frames +// as child captures keyed by their attribute frame index inside the snapshot. +internal sealed class RenderFragmentCapture +{ + private readonly RenderFragment _original; + private readonly Dictionary _childCaptures = new(); + private RenderTreeFrame[]? _capturedFrames; + + public RenderFragmentCapture(RenderFragment original) + { + _original = original; + } + + public IReadOnlyDictionary ChildCaptures => _childCaptures; + + public void Invoke(RenderTreeBuilder builder) + { + var start = builder.GetFrames().Count; + _original(builder); + var end = builder.GetFrames().Count; + + // Walk the produced frames and wrap any nested RenderFragment component parameters. + // Child captures are keyed by index relative to the snapshot start, so the keys + // line up with the positions seen by the serializer when it walks _capturedFrames. + WrapNestedFragments(builder, start, end); + + // Re-fetch frames after WrapNestedFragments may have mutated attribute values. + var frames = builder.GetFrames(); + var count = end - start; + _capturedFrames = new RenderTreeFrame[count]; + Array.Copy(frames.Array, start, _capturedFrames, 0, count); + } + + public RenderTreeFrame[] GetCapturedFrames() + { + if (_capturedFrames is null) + { + throw new InvalidOperationException("Cannot retrieve captured frames because the RenderFragment was not invoked during rendering."); + } + return _capturedFrames; + } + + private void WrapNestedFragments(RenderTreeBuilder builder, int start, int end) + { + var frames = builder.GetFrames(); + for (var i = start; i < end; i++) + { + ref readonly var frame = ref frames.Array[i]; + if (frame.FrameType is not RenderTreeFrameType.Component) + { + continue; + } + + var componentSubtreeEnd = i + frame.ComponentSubtreeLength; + // A component's attribute frames always sit right after the Component + // frame in the render tree, with no other frame types mixed in. So we can stop as + // soon as we hit the first non-Attribute frame. + for (var j = i + 1; j < componentSubtreeEnd && frames.Array[j].FrameType is RenderTreeFrameType.Attribute; j++) + { + ref readonly var attrFrame = ref frames.Array[j]; + if (attrFrame.AttributeValue is RenderFragment innerRf) + { + var innerCapture = new RenderFragmentCapture(innerRf); + // Replace the original delegate in the live render buffer with the wrapper. + // This is required so that when the nested component later invokes its + // RenderFragment parameter, control flows through innerCapture.Invoke and + // populates innerCapture._capturedFrames. Looking up the capture by frame + // index in _childCaptures would otherwise find an entry whose frames were + // never recorded. + builder.SetAttributeValue(j, (RenderFragment)innerCapture.Invoke); + _childCaptures[j - start] = innerCapture; + } + } + } + } +} diff --git a/src/Components/Shared/src/RenderFragmentSerializer.cs b/src/Components/Shared/src/RenderFragmentSerializer.cs new file mode 100644 index 000000000000..54b053c53b92 --- /dev/null +++ b/src/Components/Shared/src/RenderFragmentSerializer.cs @@ -0,0 +1,422 @@ +// 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; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components; + +internal static partial class RenderFragmentSerializer +{ + internal const string SerializedRenderFragmentValueType = "#RenderFragment"; + + /// Converts the captured render tree frames from a into a + /// JSON-serializable tree of objects. This is the entry point for + /// serializing a RenderFragment that was captured in renderer. + internal static List SerializeFrames( + RenderFragmentCapture capture, + ILogger logger, + string? ownerComponentType = null) + { + var result = new List(); + var frames = capture.GetCapturedFrames(); + var position = 0; + SerializeChildren(ref position, frames, frames.Length, result, capture, logger, ownerComponentType); + return result; + } + + /// Walks a contiguous range of render tree frames and converts each into a , + /// recursively processing element children, component parameters, and nested RenderFragment captures. + /// Non-serializable frames (event handlers, @ref captures, @rendermode, @formname) are skipped with warnings. + private static void SerializeChildren( + ref int position, + ReadOnlySpan frames, + int endExcl, + List target, + RenderFragmentCapture currentCapture, + ILogger logger, + string? ownerComponentType) + { + while (position < endExcl) + { + ref readonly var frame = ref frames[position]; + switch (frame.FrameType) + { + case RenderTreeFrameType.Element: + { + var node = new RenderTreeNode + { + Type = "element", + Tag = frame.ElementName, + }; + if (frame.ElementKey is not null) + { + node.Key = frame.ElementKey; + node.KeyTypeName = frame.ElementKey.GetType().FullName; + node.KeyTypeAssembly = frame.ElementKey.GetType().Assembly.GetName().Name; + } + + var subtreeEnd = position + frame.ElementSubtreeLength; + position++; + + // Collect attributes + while (position < subtreeEnd && frames[position].FrameType is RenderTreeFrameType.Attribute) + { + ref readonly var attrFrame = ref frames[position]; + if (TrySerializeAttribute(attrFrame, position, currentCapture, logger, ownerComponentType, out var attr)) + { + node.Attributes ??= new(); + node.Attributes.Add(attr); + } + position++; + } + + // Collect children + if (position < subtreeEnd) + { + node.Children = new(); + SerializeChildren(ref position, frames, subtreeEnd, node.Children, currentCapture, logger, ownerComponentType); + } + + target.Add(node); + break; + } + case RenderTreeFrameType.Text: + target.Add(new RenderTreeNode { Type = "text", Content = frame.TextContent }); + position++; + break; + case RenderTreeFrameType.Markup: + target.Add(new RenderTreeNode { Type = "markup", Content = frame.MarkupContent }); + position++; + break; + case RenderTreeFrameType.Component: + { + var node = new RenderTreeNode + { + Type = "component", + TypeName = frame.ComponentType?.FullName, + TypeAssembly = frame.ComponentType?.Assembly.GetName().Name, + }; + if (frame.ComponentKey is not null) + { + node.Key = frame.ComponentKey; + node.KeyTypeName = frame.ComponentKey.GetType().FullName; + node.KeyTypeAssembly = frame.ComponentKey.GetType().Assembly.GetName().Name; + } + + var subtreeEnd = position + frame.ComponentSubtreeLength; + position++; + + // Collect component's attribute frames + while (position < subtreeEnd && frames[position].FrameType is RenderTreeFrameType.Attribute) + { + ref readonly var attrFrame = ref frames[position]; + if (TrySerializeAttribute(attrFrame, position, currentCapture, logger, ownerComponentType, out var attr)) + { + node.ComponentParameters ??= new(); + node.ComponentParameters.Add(attr); + } + position++; + } + + position = subtreeEnd; + target.Add(node); + break; + } + case RenderTreeFrameType.Region: + { + var subtreeEnd = position + frame.RegionSubtreeLength; + position++; + // Inline region's children into the parent + SerializeChildren(ref position, frames, subtreeEnd, target, currentCapture, logger, ownerComponentType); + break; + } + case RenderTreeFrameType.ElementReferenceCapture: + Log.ElementReferenceCaptureSkipped(logger, ownerComponentType); + position++; + break; + case RenderTreeFrameType.ComponentReferenceCapture: + Log.ComponentReferenceCaptureSkipped(logger, ownerComponentType); + position++; + break; + case RenderTreeFrameType.ComponentRenderMode: + Log.ComponentRenderModeSkipped(logger, ownerComponentType); + position++; + break; + case RenderTreeFrameType.NamedEvent: + Log.NamedEventSkipped(logger, ownerComponentType); + position++; + break; + case RenderTreeFrameType.Attribute: + // Stray attribute outside an element/component — skip + position++; + break; + + default: + throw new NotImplementedException($"Serialization for frame type '{frame.FrameType}' is not implemented."); + } + } + } + + private static bool TrySerializeAttribute( + in RenderTreeFrame frame, + int frameIndex, + RenderFragmentCapture currentCapture, + ILogger logger, + string? ownerComponentType, + [NotNullWhen(true)] out RenderTreeAttribute? result) + { + result = null; + if (frame.AttributeValue is RenderFragment) + { + if (!currentCapture.ChildCaptures.TryGetValue(frameIndex, out var nestedCapture)) + { + // If we get here, then it means that wrapping didn't happen, or it was not executed (e.g. SSRRenderBoundary in the case of disabled prerendering). + // TODO: https://github.com/dotnet/aspnetcore/issues/66739 - Add an opt-in annotation that tells the + // serializer to execute the RenderFragment directly when it wasn't invoked during prerendering. + throw new InvalidOperationException( + $"Cannot serialize RenderFragment parameter '{frame.AttributeName}' because it was not captured during rendering."); + } + + result = new RenderTreeAttribute + { + Name = frame.AttributeName!, + Value = new SerializedRenderFragment { Nodes = SerializeFrames(nestedCapture, logger, ownerComponentType) }, + TypeName = SerializedRenderFragmentValueType, + }; + return true; + } + + if (frame.AttributeValue is Delegate d) + { + if (d.GetType().IsGenericType && d.GetType().GetGenericTypeDefinition() == typeof(RenderFragment<>)) + { + Log.GenericRenderFragmentSkipped(logger, frame.AttributeName, ownerComponentType); + return false; + } + + Log.EventHandlerSkipped(logger, frame.AttributeName, ownerComponentType); + return false; + } + + if (IsEventCallback(frame.AttributeValue)) + { + Log.EventHandlerSkipped(logger, frame.AttributeName, ownerComponentType); + return false; + } + + result = new RenderTreeAttribute + { + Name = frame.AttributeName!, + Value = frame.AttributeValue, + TypeName = frame.AttributeValue?.GetType().FullName, + TypeAssembly = frame.AttributeValue?.GetType().Assembly.GetName().Name, + }; + return true; + } + + internal static RenderFragment Deserialize(List nodes, JsonSerializerOptions jsonOptions, ComponentParametersTypeCache typeCache) + { + return builder => DeserializeNodes(builder, nodes, jsonOptions, typeCache); + } + + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Component types referenced in serialized RenderFragments are expected to be preserved by the application.")] + private static void DeserializeNodes(RenderTreeBuilder builder, List nodes, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) + { + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + switch (node.Type) + { + case "element": + builder.OpenElement(0, node.Tag!); + if (node.Key is not null) + { + builder.SetKey(node.Key is JsonElement je ? ConvertTypedValue(je, node.KeyTypeAssembly!, node.KeyTypeName!, jsonOptions, typeCache) : node.Key); + } + DeserializeAttributes(builder, node.Attributes, jsonOptions, typeCache); + if (node.Children is not null) + { + DeserializeNodes(builder, node.Children, jsonOptions, typeCache); + } + builder.CloseElement(); + break; + + case "text": + builder.AddContent(0, node.Content); + break; + + case "markup": + builder.AddMarkupContent(0, node.Content); + break; + + case "component": + var componentType = typeCache.GetParameterType(node.TypeAssembly!, node.TypeName!); + if (componentType is null) + { + throw new InvalidOperationException($"Cannot resolve component type '{node.TypeName}' from assembly '{node.TypeAssembly}'."); + } + builder.OpenComponent(0, componentType); + if (node.Key is not null) + { + builder.SetKey(node.Key is JsonElement je ? ConvertTypedValue(je, node.KeyTypeAssembly!, node.KeyTypeName!, jsonOptions, typeCache) : node.Key); + } + DeserializeComponentParameters(builder, node.ComponentParameters, jsonOptions, typeCache); + builder.CloseComponent(); + break; + + default: + throw new NotImplementedException($"Deserialization for node type '{node.Type}' is not implemented."); + } + } + } + + private static void DeserializeAttributes(RenderTreeBuilder builder, List? attributes, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) + { + if (attributes is null) + { + return; + } + + foreach (var attr in attributes) + { + var value = attr.Value is JsonElement je + ? ConvertTypedValue(je, attr.TypeAssembly!, attr.TypeName!, jsonOptions, typeCache) + : attr.Value; + builder.AddAttribute(0, attr.Name, value); + } + } + + private static void DeserializeComponentParameters(RenderTreeBuilder builder, List? parameters, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) + { + if (parameters is null) + { + return; + } + + foreach (var param in parameters) + { + var value = DeserializeAttributeValue(param, jsonOptions, typeCache); + builder.AddComponentParameter(0, param.Name, value); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "SerializedRenderFragment and its members are preserved by existing DynamicDependency attributes.")] + private static object? DeserializeAttributeValue(RenderTreeAttribute attr, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) + { + if (attr.TypeName == SerializedRenderFragmentValueType) + { + var serialized = attr.Value switch + { + JsonElement je => JsonSerializer.Deserialize(je.GetRawText(), jsonOptions), + SerializedRenderFragment sf => sf, + _ => throw new InvalidOperationException($"Unexpected value type '{attr.Value?.GetType()}' for serialized RenderFragment attribute '{attr.Name}'.") + }; + return Deserialize(serialized!.Nodes, jsonOptions!, typeCache); + } + + return attr.Value is JsonElement json ? ConvertTypedValue(json, attr.TypeAssembly!, attr.TypeName!, jsonOptions, typeCache) : attr.Value; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Attribute and key types are primitive or well-known types preserved by the application.")] + private static object? ConvertTypedValue(JsonElement json, string assemblyName, string typeName, JsonSerializerOptions? jsonOptions, ComponentParametersTypeCache typeCache) + { + var type = typeCache.GetParameterType(assemblyName, typeName) ?? throw new InvalidOperationException($"Could not resolve serialized type '{typeName}' from assembly '{assemblyName}'."); + return json.Deserialize(type, jsonOptions); + } + + private static bool IsEventCallback(object? value) + { + if (value is null) + { + return false; + } + + if (value is EventCallback) + { + return true; + } + + var type = value.GetType(); + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(EventCallback<>); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Event handler '{AttributeName}' inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Delegates cannot cross render mode boundaries.", EventName = "EventHandlerSkipped")] + public static partial void EventHandlerSkipped(ILogger logger, string? attributeName, string? ownerComponentType); + + [LoggerMessage(2, LogLevel.Warning, "An element @ref capture inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Element references cannot cross render mode boundaries.", EventName = "ElementReferenceCaptureSkipped")] + public static partial void ElementReferenceCaptureSkipped(ILogger logger, string? ownerComponentType); + + [LoggerMessage(3, LogLevel.Warning, "A component @ref capture inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Component references cannot cross render mode boundaries.", EventName = "ComponentReferenceCaptureSkipped")] + public static partial void ComponentReferenceCaptureSkipped(ILogger logger, string? ownerComponentType); + + [LoggerMessage(4, LogLevel.Warning, "A @rendermode directive inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. The render mode is already determined by the boundary the RenderFragment is crossing.", EventName = "ComponentRenderModeSkipped")] + public static partial void ComponentRenderModeSkipped(ILogger logger, string? ownerComponentType); + + [LoggerMessage(5, LogLevel.Warning, "A @formname directive inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Named events are an SSR-only mechanism and cannot cross render mode boundaries.", EventName = "NamedEventSkipped")] + public static partial void NamedEventSkipped(ILogger logger, string? ownerComponentType); + + [LoggerMessage(6, LogLevel.Warning, "A generic RenderFragment parameter '{AttributeName}' inside a RenderFragment on component '{OwnerComponentType}' was skipped during serialization. Only non-generic RenderFragment is supported across render mode boundaries.", EventName = "GenericRenderFragmentSkipped")] + public static partial void GenericRenderFragmentSkipped(ILogger logger, string? attributeName, string? ownerComponentType); + } +} + +internal sealed class SerializedRenderFragment +{ + public List Nodes { get; init; } = []; +} + +internal sealed class RenderTreeNode +{ + public string Type { get; set; } = ""; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Tag { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Attributes { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Key { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? KeyTypeName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? KeyTypeAssembly { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Content { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TypeName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TypeAssembly { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ComponentParameters { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } +} + +internal sealed class RenderTreeAttribute +{ + public string Name { get; set; } = ""; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TypeName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TypeAssembly { get; set; } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index 41b42bf2c662..be9202e79b46 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -33,6 +33,8 @@ + + diff --git a/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs b/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs index 0c4884243585..6c53fbabda51 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs @@ -20,6 +20,9 @@ public WebAssemblyComponentParameterDeserializer( public static WebAssemblyComponentParameterDeserializer Instance { get; } = new WebAssemblyComponentParameterDeserializer(new ComponentParametersTypeCache()); + [DynamicDependency(JsonSerialized, typeof(SerializedRenderFragment))] + [DynamicDependency(JsonSerialized, typeof(RenderTreeNode))] + [DynamicDependency(JsonSerialized, typeof(RenderTreeAttribute))] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "We expect application code is configured to preserve component parameter types.")] public ParameterView DeserializeParameters(IList parametersDefinitions, IList parameterValues) { @@ -47,10 +50,26 @@ public ParameterView DeserializeParameters(IList parametersD { throw new InvalidOperationException($"The parameter definition for '{definition.Name}' is incomplete: Type='{definition.TypeName}' Assembly='{definition.Assembly}'."); } + else if (definition.TypeName == typeof(SerializedRenderFragment).FullName + && definition.Assembly == "Microsoft.AspNetCore.Components.Endpoints") + { + try + { + var value = (JsonElement)parameterValues[i]; + var serialized = JsonSerializer.Deserialize( + value.GetRawText(), + WebAssemblyComponentSerializationSettings.JsonSerializationOptions); + parametersDictionary[definition.Name] = RenderFragmentSerializer.Deserialize(serialized!.Nodes, WebAssemblyComponentSerializationSettings.JsonSerializationOptions, _parametersCache); + } + catch (Exception e) + { + throw new InvalidOperationException($"Could not parse the parameter value for parameter '{definition.Name}' of type '{definition.TypeName}' and assembly '{definition.Assembly}'.", e); + } + } else { var parameterType = _parametersCache.GetParameterType(definition.Assembly, definition.TypeName); - if (parameterType == null) + if (parameterType is null) { throw new InvalidOperationException($"The parameter '{definition.Name}' with type '{definition.TypeName}' in assembly '{definition.Assembly}' could not be found."); } @@ -61,7 +80,6 @@ public ParameterView DeserializeParameters(IList parametersD value.GetRawText(), parameterType, WebAssemblyComponentSerializationSettings.JsonSerializationOptions); - parametersDictionary[definition.Name] = parameterValue; } catch (Exception e) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/RenderFragmentSerializationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/RenderFragmentSerializationTest.cs new file mode 100644 index 000000000000..7d7d16543fca --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/RenderFragmentSerializationTest.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; + +public class RenderFragmentSerializationTest : ServerTestBase>> +{ + public RenderFragmentSerializationTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + public override Task InitializeAsync() + => InitializeAsync(BrowserFixture.StreamingContext); + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_SimpleText(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=1&mode={mode}"); + + Browser.Equal("Hello from SSR", () => Browser.FindElement(By.Id("simple-text")).Text); + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test1")).Text); + + Browser.Click(By.Id("increment-test1")); + Browser.Equal("1", () => Browser.FindElement(By.Id("count-test1")).Text); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_NestedElements(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=2&mode={mode}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test2")).Text); + + Browser.Contains("Title", () => Browser.FindElement(By.Id("nested-elements")).FindElement(By.TagName("h2")).Text); + Browser.Equal(2, () => Browser.FindElement(By.Id("nested-elements")).FindElements(By.TagName("li")).Count); + Browser.Equal("Item 1", () => Browser.FindElement(By.Id("nested-elements")).FindElements(By.TagName("li"))[0].Text); + Browser.Equal("Item 2", () => Browser.FindElement(By.Id("nested-elements")).FindElements(By.TagName("li"))[1].Text); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_MixedContent(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=3&mode={mode}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test3")).Text); + + Browser.Contains("bold", () => Browser.FindElement(By.Id("mixed-content")).FindElement(By.TagName("strong")).Text); + Browser.Contains("italic", () => Browser.FindElement(By.Id("mixed-content")).FindElement(By.TagName("em")).Text); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_WithAttributes(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=4&mode={mode}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test4")).Text); + + Browser.Equal("Attributed content", () => Browser.FindElement(By.Id("with-attributes")).Text); + Browser.Equal("styled", () => Browser.FindElement(By.Id("with-attributes")).GetAttribute("class")); + Browser.Equal("42", () => Browser.FindElement(By.Id("with-attributes")).GetAttribute("data-value")); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_NestedRenderFragment(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=5&mode={mode}"); + + Browser.Equal("Nested RenderFragment content", () => Browser.FindElement(By.Id("nested-child")).Text); + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test5")).Text); + + Browser.Click(By.Id("increment-test5")); + Browser.Equal("1", () => Browser.FindElement(By.Id("count-test5")).Text); + + Browser.Click(By.Id("increment-test5-inner")); + Browser.Equal("1", () => Browser.FindElement(By.Id("count-test5-inner")).Text); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_ComponentWithTypedParameters(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=6&mode={mode}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test6")).Text); + + Browser.Equal("42", () => Browser.FindElement(By.Id("int-value-typed")).Text); + Browser.Equal("3.14", () => Browser.FindElement(By.Id("double-value-typed")).Text); + Browser.Equal("True", () => Browser.FindElement(By.Id("bool-value-typed")).Text); + } + + [Theory] + [InlineData("server")] + [InlineData("wasm")] + public void RenderFragmentCrossesBoundary_KeyedElements(string mode) + { + Navigate($"{ServerPathBase}/render-fragment-interactive?test=7&mode={mode}"); + + Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-test7")).Text); + + Browser.Equal("Item 1", () => Browser.FindElement(By.Id("keyed-1")).Text); + Browser.Equal("Item 2", () => Browser.FindElement(By.Id("keyed-2")).Text); + Browser.Equal("Item 3", () => Browser.FindElement(By.Id("keyed-3")).Text); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/RenderFragmentInteractive.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/RenderFragmentInteractive.razor new file mode 100644 index 000000000000..349e123587e2 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/RenderFragmentInteractive.razor @@ -0,0 +1,82 @@ +@page "/render-fragment-interactive" +@using Microsoft.AspNetCore.Components.Web + +

RenderFragment crossing interactive boundary

+ +@{ + IComponentRenderMode renderMode = Mode switch + { + "wasm" => new InteractiveWebAssemblyRenderMode(), + _ => new InteractiveServerRenderMode(), + }; +} + +@if (Test == 1) +{ + +

Hello from SSR

+
+} + +@if (Test == 2) +{ + +
+

Title

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+
+} + +@if (Test == 3) +{ + + Text and bold and italic + +} + +@if (Test == 4) +{ + +
Attributed content
+
+} + +@if (Test == 5) +{ + + +

Nested RenderFragment content

+
+
+} + +@if (Test == 6) +{ + + + +} + +@if (Test == 7) +{ + +
+ @for (var i = 1; i <= 3; i++) + { + Item @i + } +
+
+} + +@code { + [Parameter, SupplyParameterFromQuery(Name = "test")] + public int Test { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "mode")] + public string Mode { get; set; } = "server"; +} diff --git a/src/Components/test/testassets/TestContentPackage/RenderFragmentChild.razor b/src/Components/test/testassets/TestContentPackage/RenderFragmentChild.razor new file mode 100644 index 000000000000..c690947a94f9 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/RenderFragmentChild.razor @@ -0,0 +1,33 @@ +
+

Interactive: @_isInteractive

+
+ @ChildContent +
+ +

Count: @_currentCount

+
+ +@code { + private int _currentCount = 0; + private bool _isInteractive = false; + + [Parameter, EditorRequired] + public string IdSuffix { get; set; } = default!; + + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; + + private void IncrementCount() + { + _currentCount++; + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _isInteractive = true; + StateHasChanged(); + } + } +} diff --git a/src/Components/test/testassets/TestContentPackage/TypedParameterDisplay.razor b/src/Components/test/testassets/TestContentPackage/TypedParameterDisplay.razor new file mode 100644 index 000000000000..ee16c806861c --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/TypedParameterDisplay.razor @@ -0,0 +1,19 @@ +
+ @Count + @Score + @IsActive +
+ +@code { + [Parameter, EditorRequired] + public string IdSuffix { get; set; } = default!; + + [Parameter] + public int Count { get; set; } + + [Parameter] + public double Score { get; set; } + + [Parameter] + public bool IsActive { get; set; } +}