Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Components.Server;
Expand Down Expand Up @@ -76,13 +77,31 @@ public bool TryDeserializeParameters(IList<ComponentParameter> parametersDefinit
}
try
{
// At this point we know the parameter is not null, as we don't serialize the type name or the assembly name
// for null parameters.
var value = (JsonElement)parameterValues[i];
var parameterValue = JsonSerializer.Deserialize(
value.GetRawText(),
parameterType,
ServerComponentSerializationSettings.JsonSerializationOptions);
object? parameterValue;
if (parameterValues[i] is null && IsUnion(parameterType))
{
// A union whose active case serializes to JSON null (for example a Union(int?, string)
// holding a null int?) is still a non-null box, so the prerender protocol records its
// type name like any other typed parameter. The value itself serializes to JSON null,
// which materializes as a CLR null in the object-typed parameter values array rather than
// as a JsonElement. Route the JSON null literal back through the union converter so the
// original active case is restored instead of failing the JsonElement cast below.
parameterValue = JsonSerializer.Deserialize(
"null",
parameterType,
ServerComponentSerializationSettings.JsonSerializationOptions);
}
else
{
// At this point we know the parameter is not null, as we don't serialize the type name or the assembly name
// for null parameters.
var value = (JsonElement)parameterValues[i];
parameterValue = JsonSerializer.Deserialize(
value.GetRawText(),
parameterType,
ServerComponentSerializationSettings.JsonSerializationOptions);
}

parametersDictionary.Add(definition.Name, parameterValue);
}
catch (Exception e)
Expand All @@ -97,6 +116,12 @@ public bool TryDeserializeParameters(IList<ComponentParameter> parametersDefinit
return true;
}

// A C# union is the only typed parameter whose value can legitimately serialize to JSON null while still
// recording a non-null type name (the box is non-null, but its active case can be a null int? or reference).
private static bool IsUnion(Type parameterType)
=> ServerComponentSerializationSettings.JsonSerializationOptions
.GetTypeInfo(parameterType).Kind == JsonTypeInfoKind.Union;

private static partial class Log
{
[LoggerMessage(1, LogLevel.Debug, "Parameter values must be an array.", EventName = "ParameterValuesInvalidFormat")]
Expand Down
Loading
Loading