Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/Components/Components/test/ParameterViewTest.Unions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Microsoft.AspNetCore.Components;

// Verifies that C# union types work as component [Parameter] values. Parameters passed from a
// parent to a child component (Razor markup / RenderTreeBuilder.AddComponentParameter) are plain
// in-process CLR object assignment — no JSON serialization is involved — so any union shape works.
public partial class ParameterViewTest
{
[Fact]
public void IncomingUnionParameter_UnambiguousIntCase_SetsValue()
{
var parameters = new ParameterViewBuilder
{
{ nameof(HasUnionParameters.IntStringValue), new UnionIntString(42) },
}.Build();
var target = new HasUnionParameters();

parameters.SetParameterProperties(target);

Assert.Equal(new UnionIntString(42), target.IntStringValue);
}

[Fact]
public void IncomingUnionParameter_UnambiguousStringCase_SetsValue()
{
var parameters = new ParameterViewBuilder
{
{ nameof(HasUnionParameters.IntStringValue), new UnionIntString("hi") },
}.Build();
var target = new HasUnionParameters();

parameters.SetParameterProperties(target);

Assert.Equal(new UnionIntString("hi"), target.IntStringValue);
}

[Fact]
public void IncomingUnionParameter_NullableNullCase_SetsValue()
{
var parameters = new ParameterViewBuilder
{
{ nameof(HasUnionParameters.NullableValue), new UnionNullableIntString((int?)null) },
}.Build();
var target = new HasUnionParameters();

parameters.SetParameterProperties(target);

Assert.Equal(new UnionNullableIntString((int?)null), target.NullableValue);
}

[Fact]
public void IncomingUnionParameter_ReferenceTypeCase_SetsSameInstance()
{
var cat = new Cat("Whiskers");
var parameters = new ParameterViewBuilder
{
{ nameof(HasUnionParameters.Pet), new UnionPet(cat) },
}.Build();
var target = new HasUnionParameters();

parameters.SetParameterProperties(target);

Assert.Equal(new UnionPet(cat), target.Pet);
Assert.Same(cat, target.Pet.Value);
}

[Fact]
public void IncomingUnionParameter_FromDictionary_SetsValue()
{
var parameters = ParameterView.FromDictionary(new Dictionary<string, object>
{
[nameof(HasUnionParameters.IntStringValue)] = new UnionIntString(7),
});
var target = new HasUnionParameters();

parameters.SetParameterProperties(target);

Assert.Equal(new UnionIntString(7), target.IntStringValue);
}

private sealed class HasUnionParameters
{
[Parameter] public UnionIntString IntStringValue { get; set; }
[Parameter] public UnionNullableIntString NullableValue { get; set; }
[Parameter] public UnionPet Pet { get; set; }
}
}

// --- Test union types (shared across the Components.Tests union tests) ---

// Unambiguous primitive-paired union.
public union UnionIntString(int, string);

// Nullable value-type case.
public union UnionNullableIntString(int?, string);

// Reference-type cases.
public record Cat(string Name);
public record Dog(string Breed);
public union UnionPet(Cat, Dog);

// Classifier-disambiguated variant of UnionPet, used by the persisted-state round-trip test where
// the read side needs to disambiguate two object cases. Null is handled by the runtime fast-path,
// so the classifier does not branch on JsonTokenType.Null.
[JsonUnion(TypeClassifier = typeof(UnionPetClassifierFactory))]
public union UnionPetWithClassifier(Cat, Dog);

public sealed class UnionPetClassifierFactory : JsonTypeClassifierFactory<UnionPetWithClassifier>
{
public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, System.Text.Json.JsonSerializerOptions options) =>
static (ref System.Text.Json.Utf8JsonReader reader) =>
{
if (reader.TokenType != System.Text.Json.JsonTokenType.StartObject)
{
return null;
}

var clone = reader;
clone.Read();
while (clone.TokenType == System.Text.Json.JsonTokenType.PropertyName)
{
if (clone.ValueTextEquals("name") || clone.ValueTextEquals("Name"))
{
return typeof(Cat);
}
if (clone.ValueTextEquals("breed") || clone.ValueTextEquals("Breed"))
{
return typeof(Dog);
}

clone.Read();
clone.Skip();
clone.Read();
}

return null;
};
}

// Envelope record that holds a union as a property.
public record UnionEnvelope(string CorrelationId, UnionIntString Payload);
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,85 @@ public void TryRetrieveFromJson_NullValue()
Assert.False(applicationState.TryTakeFromJson<byte[]>("MyState", out _));
}

// C# union types persist through PersistAsJson / TryTakeFromJson, which use the default
// System.Text.Json options (with native union support).

[Theory]
[MemberData(nameof(UnionRoundTripCases))]
public void Union_RoundTripsThroughPersistedState(UnionIntString value)
{
// Arrange
var store = new Dictionary<string, byte[]>();
var persisting = new PersistentComponentState(store, [], []) { PersistingState = true };
persisting.PersistAsJson("MyState", value);

var restoring = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
restoring.InitializeExistingState(store, RestoreContext.InitialValue);

// Act
Assert.True(restoring.TryTakeFromJson<UnionIntString>("MyState", out var restored));

// Assert
Assert.Equal(value, restored);
}

public static TheoryData<UnionIntString> UnionRoundTripCases() =>
new() { new UnionIntString(42), new UnionIntString("hi") };

[Fact]
public void Union_NullableNullCase_RoundTripsThroughPersistedState()
{
// Arrange
var store = new Dictionary<string, byte[]>();
var persisting = new PersistentComponentState(store, [], []) { PersistingState = true };
persisting.PersistAsJson("MyState", new UnionNullableIntString((int?)null));

var restoring = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
restoring.InitializeExistingState(store, RestoreContext.InitialValue);

// Act
Assert.True(restoring.TryTakeFromJson<UnionNullableIntString>("MyState", out var restored));

// Assert
Assert.Equal(new UnionNullableIntString((int?)null), restored);
}

[Fact]
public void Union_ObjectCaseWithClassifier_RoundTripsThroughPersistedState()
{
// Arrange
var store = new Dictionary<string, byte[]>();
var persisting = new PersistentComponentState(store, [], []) { PersistingState = true };
persisting.PersistAsJson("MyState", new UnionPetWithClassifier(new Dog("Labrador")));

var restoring = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
restoring.InitializeExistingState(store, RestoreContext.InitialValue);

// Act
Assert.True(restoring.TryTakeFromJson<UnionPetWithClassifier>("MyState", out var restored));

// Assert
Assert.Equal(new UnionPetWithClassifier(new Dog("Labrador")), restored);
}

[Fact]
public void Union_InsideEnvelope_RoundTripsThroughPersistedState()
{
// Arrange
var store = new Dictionary<string, byte[]>();
var persisting = new PersistentComponentState(store, [], []) { PersistingState = true };
persisting.PersistAsJson("MyState", new UnionEnvelope("abc", new UnionIntString(42)));

var restoring = new PersistentComponentState(new Dictionary<string, byte[]>(), [], []);
restoring.InitializeExistingState(store, RestoreContext.InitialValue);

// Act
Assert.True(restoring.TryTakeFromJson<UnionEnvelope>("MyState", out var restored));

// Assert
Assert.Equal(new UnionEnvelope("abc", new UnionIntString(42)), restored);
}

[Fact]
public void RegisterOnRestoring_InvokesCallbackWhenShouldRestoreMatches()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.AspNetCore.Components.Server.Circuits;

// Verifies that C# union types survive the Server prerendering round-trip. Parameter values are
// serialized into the component marker with the runtime type of the value (the union type) and
// deserialized back by ComponentParameterDeserializer using that type with
// ServerComponentSerializationSettings.JsonSerializationOptions (the default System.Text.Json
// reflection resolver, which has native union support).
public partial class ServerComponentDeserializerTest
{
[Fact]
public void Union_UnambiguousIntCase_RoundTripsThroughPrerenderParameters()
{
var markers = SerializeMarkers(CreateMarkers(
(typeof(TestComponent), new Dictionary<string, object> { ["Value"] = new UnionIntString(42) })));
var serverComponentDeserializer = CreateServerComponentDeserializer();

Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors));
var parameters = Assert.Single(descriptors).Parameters.ToDictionary();
Assert.Equal(new UnionIntString(42), parameters["Value"]);
}

[Fact]
public void Union_UnambiguousStringCase_RoundTripsThroughPrerenderParameters()
{
var markers = SerializeMarkers(CreateMarkers(
(typeof(TestComponent), new Dictionary<string, object> { ["Value"] = new UnionIntString("hi") })));
var serverComponentDeserializer = CreateServerComponentDeserializer();

Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors));
var parameters = Assert.Single(descriptors).Parameters.ToDictionary();
Assert.Equal(new UnionIntString("hi"), parameters["Value"]);
}

[Fact]
public void Union_NullActiveCase_IsNotSupportedByPrerenderParameters_KnownGap()
{
// Documents a known gap in the Server prerender parameter protocol (not a union-specific
// serialization problem). The protocol records a parameter's type from the runtime type of
// the boxed value: for a union holding a null case the box is non-null, so a non-null
// TypeName/Assembly is written. But the value serializes to JSON null on the wire, and on the
// read side ComponentParameterDeserializer unconditionally casts each parameter value to
// JsonElement (parameterValues come back as CLR null for JSON null), which throws and fails
// the whole descriptor. In other words the protocol conflates "value that serializes to null"
// with "absent/null parameter". This only surfaces for types whose converter can emit null for
// a non-null instance (such as a union whose active case is a null int? or null reference).
// The in-process, JSInterop and PersistentComponentState paths all round-trip this case fine.
var markers = SerializeMarkers(CreateMarkers(
(typeof(TestComponent), new Dictionary<string, object> { ["Value"] = new UnionNullableIntString((int?)null) })));
var serverComponentDeserializer = CreateServerComponentDeserializer();

Assert.False(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out _));
}

[Fact]
public void Union_ReferenceTypeCaseWithClassifier_RoundTripsThroughPrerenderParameters()
{
var markers = SerializeMarkers(CreateMarkers(
(typeof(TestComponent), new Dictionary<string, object> { ["Value"] = new UnionPetWithClassifier(new Cat("Whiskers")) })));
var serverComponentDeserializer = CreateServerComponentDeserializer();

Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors));
var parameters = Assert.Single(descriptors).Parameters.ToDictionary();
Assert.Equal(new UnionPetWithClassifier(new Cat("Whiskers")), parameters["Value"]);
}

[Fact]
public void Union_InsideEnvelope_RoundTripsThroughPrerenderParameters()
{
var markers = SerializeMarkers(CreateMarkers(
(typeof(TestComponent), new Dictionary<string, object> { ["Value"] = new UnionEnvelope("abc", new UnionIntString(42)) })));
var serverComponentDeserializer = CreateServerComponentDeserializer();

Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors));
var parameters = Assert.Single(descriptors).Parameters.ToDictionary();
Assert.Equal(new UnionEnvelope("abc", new UnionIntString(42)), parameters["Value"]);
}
}

// --- Test union types (kept together, mirroring SharedTypes.Unions.cs) ---

// Unambiguous primitive-paired union: int and string serialize to distinct JSON tokens.
public union UnionIntString(int, string);

// Nullable value-type case. JSON null reads back as the int? case (dotnet/runtime#128688).
public union UnionNullableIntString(int?, string);

// Reference-type cases. Both serialize to JSON Object, so the read side needs a classifier to
// disambiguate.
public record Cat(string Name);
public record Dog(string Breed);

// Classifier-disambiguated reference-type union. The classifier walks the first object member to
// identify the case ("name" -> Cat, "breed" -> Dog). Null is handled by the runtime fast-path, so
// the classifier intentionally does not branch on JsonTokenType.Null.
[JsonUnion(TypeClassifier = typeof(UnionPetClassifierFactory))]
public union UnionPetWithClassifier(Cat, Dog);

public sealed class UnionPetClassifierFactory : JsonTypeClassifierFactory<UnionPetWithClassifier>
{
public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) =>
static (ref Utf8JsonReader reader) =>
{
if (reader.TokenType != JsonTokenType.StartObject)
{
return null;
}

var clone = reader;
clone.Read();
while (clone.TokenType == JsonTokenType.PropertyName)
{
if (clone.ValueTextEquals("name") || clone.ValueTextEquals("Name"))
{
return typeof(Cat);
}
if (clone.ValueTextEquals("breed") || clone.ValueTextEquals("Breed"))
{
return typeof(Dog);
}

clone.Read();
clone.Skip();
clone.Read();
}

return null;
};
}

// Envelope record that holds a union as a property.
public record UnionEnvelope(string CorrelationId, UnionIntString Payload);
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace Microsoft.AspNetCore.Components.Server.Circuits;

public class ServerComponentDeserializerTest
public partial class ServerComponentDeserializerTest
{
private readonly IDataProtectionProvider _ephemeralDataProtectionProvider;
private readonly ITimeLimitedDataProtector _protector;
Expand Down
Loading
Loading