diff --git a/src/Components/Components/test/ParameterViewTest.Unions.cs b/src/Components/Components/test/ParameterViewTest.Unions.cs new file mode 100644 index 000000000000..99b4051f96dd --- /dev/null +++ b/src/Components/Components/test/ParameterViewTest.Unions.cs @@ -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 + { + [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 +{ + 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); diff --git a/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs b/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs index a346e84645f8..14014076e13a 100644 --- a/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs @@ -188,6 +188,85 @@ public void TryRetrieveFromJson_NullValue() Assert.False(applicationState.TryTakeFromJson("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(); + var persisting = new PersistentComponentState(store, [], []) { PersistingState = true }; + persisting.PersistAsJson("MyState", value); + + var restoring = new PersistentComponentState(new Dictionary(), [], []); + restoring.InitializeExistingState(store, RestoreContext.InitialValue); + + // Act + Assert.True(restoring.TryTakeFromJson("MyState", out var restored)); + + // Assert + Assert.Equal(value, restored); + } + + public static TheoryData UnionRoundTripCases() => + new() { new UnionIntString(42), new UnionIntString("hi") }; + + [Fact] + public void Union_NullableNullCase_RoundTripsThroughPersistedState() + { + // Arrange + var store = new Dictionary(); + var persisting = new PersistentComponentState(store, [], []) { PersistingState = true }; + persisting.PersistAsJson("MyState", new UnionNullableIntString((int?)null)); + + var restoring = new PersistentComponentState(new Dictionary(), [], []); + restoring.InitializeExistingState(store, RestoreContext.InitialValue); + + // Act + Assert.True(restoring.TryTakeFromJson("MyState", out var restored)); + + // Assert + Assert.Equal(new UnionNullableIntString((int?)null), restored); + } + + [Fact] + public void Union_ObjectCaseWithClassifier_RoundTripsThroughPersistedState() + { + // Arrange + var store = new Dictionary(); + var persisting = new PersistentComponentState(store, [], []) { PersistingState = true }; + persisting.PersistAsJson("MyState", new UnionPetWithClassifier(new Dog("Labrador"))); + + var restoring = new PersistentComponentState(new Dictionary(), [], []); + restoring.InitializeExistingState(store, RestoreContext.InitialValue); + + // Act + Assert.True(restoring.TryTakeFromJson("MyState", out var restored)); + + // Assert + Assert.Equal(new UnionPetWithClassifier(new Dog("Labrador")), restored); + } + + [Fact] + public void Union_InsideEnvelope_RoundTripsThroughPersistedState() + { + // Arrange + var store = new Dictionary(); + var persisting = new PersistentComponentState(store, [], []) { PersistingState = true }; + persisting.PersistAsJson("MyState", new UnionEnvelope("abc", new UnionIntString(42))); + + var restoring = new PersistentComponentState(new Dictionary(), [], []); + restoring.InitializeExistingState(store, RestoreContext.InitialValue); + + // Act + Assert.True(restoring.TryTakeFromJson("MyState", out var restored)); + + // Assert + Assert.Equal(new UnionEnvelope("abc", new UnionIntString(42)), restored); + } + [Fact] public void RegisterOnRestoring_InvokesCallbackWhenShouldRestoreMatches() { diff --git a/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs index 161346b417fe..c0b4b8688336 100644 --- a/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs +++ b/src/Components/Server/src/Circuits/ComponentParameterDeserializer.cs @@ -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; @@ -76,13 +77,31 @@ public bool TryDeserializeParameters(IList 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) @@ -97,6 +116,12 @@ public bool TryDeserializeParameters(IList 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")] diff --git a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.Unions.cs b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.Unions.cs new file mode 100644 index 000000000000..49bf432d1149 --- /dev/null +++ b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.Unions.cs @@ -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; +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 { ["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 { ["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_NullableNullCase_RoundTripsThroughPrerenderParameters() + { + // A union whose active case is a null int? serializes to JSON null on the wire. The prerender + // protocol still records a non-null type name (the union box itself is non-null), so the read + // side restores the value by routing the JSON null literal back through the union converter + // (ComponentParameterDeserializer special-cases JsonTypeInfoKind.Union for this). + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["Value"] = new UnionNullableIntString((int?)null) }))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + var parameters = Assert.Single(descriptors).Parameters.ToDictionary(); + Assert.Equal(new UnionNullableIntString((int?)null), parameters["Value"]); + } + + [Fact] + public void Union_NullableIntCase_RoundTripsThroughPrerenderParameters() + { + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["Value"] = new UnionNullableIntString(7) }))); + var serverComponentDeserializer = CreateServerComponentDeserializer(); + + Assert.True(serverComponentDeserializer.TryDeserializeComponentDescriptorCollection(markers, out var descriptors)); + var parameters = Assert.Single(descriptors).Parameters.ToDictionary(); + Assert.Equal(new UnionNullableIntString(7), parameters["Value"]); + } + + [Fact] + public void Union_ReferenceTypeCaseWithClassifier_RoundTripsThroughPrerenderParameters() + { + var markers = SerializeMarkers(CreateMarkers( + (typeof(TestComponent), new Dictionary { ["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 { ["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 +{ + 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); diff --git a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs index 1950bba84d1d..4123bb9c8dec 100644 --- a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs +++ b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs @@ -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; diff --git a/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs b/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs index 6c53fbabda51..0c6ae55e51be 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Prerendering/WebAssemblyComponentParameterDeserializer.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Components.WebAssembly.Infrastructure; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -75,11 +76,30 @@ public ParameterView DeserializeParameters(IList parametersD } try { - var value = (JsonElement)parameterValues[i]; - var parameterValue = JsonSerializer.Deserialize( - value.GetRawText(), - parameterType, - WebAssemblyComponentSerializationSettings.JsonSerializationOptions); + object? parameterValue; + if (parameterValues[i] is null && + WebAssemblyComponentSerializationSettings.JsonSerializationOptions.GetTypeInfo(parameterType).Kind == JsonTypeInfoKind.Union) + { + // 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, + WebAssemblyComponentSerializationSettings.JsonSerializationOptions); + } + else + { + var value = (JsonElement)parameterValues[i]; + parameterValue = JsonSerializer.Deserialize( + value.GetRawText(), + parameterType, + WebAssemblyComponentSerializationSettings.JsonSerializationOptions); + } + parametersDictionary[definition.Name] = parameterValue; } catch (Exception e) diff --git a/src/Components/WebAssembly/WebAssembly/test/WebAssemblyComponentParameterDeserializerUnionTest.cs b/src/Components/WebAssembly/WebAssembly/test/WebAssemblyComponentParameterDeserializerUnionTest.cs new file mode 100644 index 000000000000..836ad316bbb2 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/test/WebAssemblyComponentParameterDeserializerUnionTest.cs @@ -0,0 +1,64 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Prerendering; + +#nullable enable + +// Verifies that C# union types survive the WebAssembly prerendering parameter round-trip. The marker +// records each parameter's runtime type and serializes its value with the default System.Text.Json +// reflection resolver (which has native union support). A union whose active case serializes to JSON +// null is the interesting case: the value comes back as a CLR null in the object-typed values array, +// and WebAssemblyComponentParameterDeserializer restores it by routing JSON null through the union +// converter for the recorded type. +public class WebAssemblyComponentParameterDeserializerUnionTest +{ + [Fact] + public void Union_NullableNullCase_RoundTripsThroughPrerenderParameters() + { + var parameters = RoundTrip(new UnionNullableIntString((int?)null)); + + Assert.Equal(new UnionNullableIntString((int?)null), parameters["Value"]); + } + + [Fact] + public void Union_NullableIntCase_RoundTripsThroughPrerenderParameters() + { + var parameters = RoundTrip(new UnionNullableIntString(7)); + + Assert.Equal(new UnionNullableIntString(7), parameters["Value"]); + } + + [Fact] + public void Union_UnambiguousStringCase_RoundTripsThroughPrerenderParameters() + { + var parameters = RoundTrip(new UnionIntString("hi")); + + Assert.Equal(new UnionIntString("hi"), parameters["Value"]); + } + + private static IReadOnlyDictionary RoundTrip(T value) + { + var (definitions, values) = ComponentParameter.FromParameterView( + ParameterView.FromDictionary(new Dictionary { ["Value"] = value })); + + // Mirror the marker marshalling: parameter values are serialized to JSON and read back as an + // object list, so a union active case that serializes to JSON null becomes a CLR null here. + var json = JsonSerializer.Serialize(values, WebAssemblyComponentSerializationSettings.JsonSerializationOptions); + var wireValues = WebAssemblyComponentParameterDeserializer.GetParameterValues(json); + + return WebAssemblyComponentParameterDeserializer.Instance + .DeserializeParameters(definitions, wireValues) + .ToDictionary(); + } +} + +// --- 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); diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherUnionTests.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherUnionTests.cs new file mode 100644 index 000000000000..14ee665822b9 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherUnionTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable +using System.Text.Json; + +namespace Microsoft.JSInterop.Infrastructure; + +// Verifies that C# union types round-trip through the [JSInvokable] dispatch path (JS -> .NET). +// DotNetDispatcher deserializes method arguments and serializes the return value using the +// JSRuntime's JsonSerializerOptions, so union support flows from System.Text.Json. +public class DotNetDispatcherUnionTests +{ + private static readonly string thisAssemblyName = typeof(DotNetDispatcherUnionTests).Assembly.GetName().Name; + + [Fact] + public void UnionParameterAndReturn_UnambiguousIntCase_RoundTrips() + { + var resultJson = Invoke("EchoUnionIntString", "[42]"); + + Assert.Equal("42", resultJson); + Assert.Equal(new UnionIntString(42), Deserialize(resultJson)); + } + + [Fact] + public void UnionParameterAndReturn_UnambiguousStringCase_RoundTrips() + { + var resultJson = Invoke("EchoUnionIntString", "[\"hi\"]"); + + Assert.Equal("\"hi\"", resultJson); + Assert.Equal(new UnionIntString("hi"), Deserialize(resultJson)); + } + + [Fact] + public void UnionParameterAndReturn_NullableNullCase_RoutesToNullableCase() + { + var resultJson = Invoke("EchoUnionNullableIntString", "[null]"); + + Assert.Equal("null", resultJson); + Assert.Equal(new UnionNullableIntString((int?)null), Deserialize(resultJson)); + } + + [Fact] + public void UnionReturn_ObjectCase_SerializesActiveCaseOnly() + { + Assert.Equal("{\"name\":\"Whiskers\"}", Invoke("ReturnCat", null)); + Assert.Equal("{\"breed\":\"Labrador\"}", Invoke("ReturnDog", null)); + } + + [Fact] + public void UnionParameterAndReturn_ClassifierResolvesAmbiguousObjectCase() + { + var resultJson = Invoke("EchoUnionPetWithClassifier", "[{\"name\":\"Whiskers\"}]"); + + Assert.Equal(new UnionPetWithClassifier(new Cat("Whiskers")), Deserialize(resultJson)); + } + + [Fact] + public void UnionInsideEnvelopeParameter_RoundTrips() + { + var resultJson = Invoke("EchoUnionEnvelope", "[{\"correlationId\":\"abc\",\"payload\":42}]"); + + Assert.Equal(new UnionEnvelope("abc", new UnionIntString(42)), Deserialize(resultJson)); + } + + private static string Invoke(string methodIdentifier, string argsJson) + => DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo(thisAssemblyName, methodIdentifier, default, default), argsJson); + + private static T Deserialize(string json) + => JsonSerializer.Deserialize(json, new TestJSRuntime().JsonSerializerOptions); +} + +public static class UnionJSInvokableTarget +{ + [JSInvokable("EchoUnionIntString")] + public static UnionIntString EchoUnionIntString(UnionIntString value) => value; + + [JSInvokable("EchoUnionNullableIntString")] + public static UnionNullableIntString EchoUnionNullableIntString(UnionNullableIntString value) => value; + + [JSInvokable("EchoUnionPetWithClassifier")] + public static UnionPetWithClassifier EchoUnionPetWithClassifier(UnionPetWithClassifier value) => value; + + [JSInvokable("EchoUnionEnvelope")] + public static UnionEnvelope EchoUnionEnvelope(UnionEnvelope value) => value; + + [JSInvokable("ReturnCat")] + public static UnionPet ReturnCat() => new UnionPet(new Cat("Whiskers")); + + [JSInvokable("ReturnDog")] + public static UnionPet ReturnDog() => new UnionPet(new Dog("Labrador")); +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeUnionTests.cs b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeUnionTests.cs new file mode 100644 index 000000000000..8e5a5707ba35 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeUnionTests.cs @@ -0,0 +1,232 @@ +// 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; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.JSInterop.Infrastructure; + +namespace Microsoft.JSInterop; + +// Verifies that C# union types round-trip through JSInterop, which serializes interop +// arguments and deserializes results with the JSRuntime's JsonSerializerOptions (the default +// System.Text.Json reflection resolver, which has native union support). +public class JSRuntimeUnionTests +{ + // --- Argument serialization (.NET -> JS): a union passed to InvokeAsync is written using + // the active case's own JSON representation, with no union envelope on the wire. --- + + [Fact] + public void UnionAsArgument_UnambiguousIntCase_SerializesAsNumber() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", new UnionIntString(42)); + + Assert.Equal("[42]", runtime.BeginInvokeCalls.Single().ArgsJson); + } + + [Fact] + public void UnionAsArgument_UnambiguousStringCase_SerializesAsString() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", new UnionIntString("hi")); + + Assert.Equal("[\"hi\"]", runtime.BeginInvokeCalls.Single().ArgsJson); + } + + [Fact] + public void UnionAsArgument_NullableNullCase_SerializesAsNullLiteral() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", new UnionNullableIntString((int?)null)); + + Assert.Equal("[null]", runtime.BeginInvokeCalls.Single().ArgsJson); + } + + [Fact] + public void UnionAsArgument_ObjectCase_SerializesActiveCaseOnly() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", new UnionPet(new Cat("Whiskers"))); + Assert.Equal("[{\"name\":\"Whiskers\"}]", runtime.BeginInvokeCalls.Single().ArgsJson); + + var dogRuntime = new CapturingJSRuntime(); + dogRuntime.InvokeAsync("identifier", new UnionPet(new Dog("Labrador"))); + Assert.Equal("[{\"breed\":\"Labrador\"}]", dogRuntime.BeginInvokeCalls.Single().ArgsJson); + } + + [Fact] + public void UnionInsideEnvelope_SerializesNestedUnion() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", new UnionEnvelope("abc", new UnionIntString(42))); + + Assert.Equal("[{\"correlationId\":\"abc\",\"payload\":42}]", runtime.BeginInvokeCalls.Single().ArgsJson); + } + + [Fact] + public void UnionMixedWithNonUnionArguments_SerializesPositionally() + { + var runtime = new CapturingJSRuntime(); + + runtime.InvokeAsync("identifier", "topic", new UnionIntString(42), 7); + + Assert.Equal("[\"topic\",42,7]", runtime.BeginInvokeCalls.Single().ArgsJson); + } + + // --- Result deserialization (JS -> .NET): a union returned from InvokeAsync is read back + // from the active case's JSON representation. --- + + [Fact] + public async Task UnionAsResult_UnambiguousIntCase_RoundTrips() + { + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "42"); + + Assert.Equal(new UnionIntString(42), await task); + } + + [Fact] + public async Task UnionAsResult_UnambiguousStringCase_RoundTrips() + { + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "\"hi\""); + + Assert.Equal(new UnionIntString("hi"), await task); + } + + [Fact] + public async Task UnionAsResult_NullableNullCase_RoutesToNullableCase() + { + // With dotnet/runtime#128688 the runtime tracks int and int? as separate cases and a + // value-based fast-path dispatches JSON null to the nullable case deterministically. + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "null"); + + Assert.Equal(new UnionNullableIntString((int?)null), await task); + } + + [Fact] + public async Task UnionAsResult_NullableValueCase_RoundTrips() + { + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "5"); + + Assert.Equal(new UnionNullableIntString((int?)5), await task); + } + + [Fact] + public async Task UnionAsResult_ClassifierResolvesAmbiguousObjectCase() + { + // Cat and Dog both serialize to a JSON object, so the read side needs a classifier to + // disambiguate. UnionPetWithClassifier dispatches by property name. + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "{\"name\":\"Whiskers\"}"); + + Assert.Equal(new UnionPetWithClassifier(new Cat("Whiskers")), await task); + } + + [Fact] + public async Task UnionInsideEnvelope_AsResult_RoundTrips() + { + var runtime = new CapturingJSRuntime(); + var task = runtime.InvokeAsync("identifier"); + + CompleteWithJson(runtime, "{\"correlationId\":\"abc\",\"payload\":42}"); + + Assert.Equal(new UnionEnvelope("abc", new UnionIntString(42)), await task); + } + + private static void CompleteWithJson(CapturingJSRuntime runtime, string json) + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + runtime.EndInvokeJS(runtime.BeginInvokeCalls.Single().AsyncHandle, succeeded: true, ref reader); + } + + private sealed class CapturingJSRuntime : JSRuntime + { + public List BeginInvokeCalls { get; } = []; + + protected override void BeginInvokeJS(long taskId, string identifier, string? argsJson, JSCallResultType resultType, long targetInstanceId) + => throw new NotImplementedException(); + + protected override void BeginInvokeJS(in JSInvocationInfo invocationInfo) + => BeginInvokeCalls.Add(invocationInfo); + + protected internal override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + => throw new NotImplementedException(); + + protected internal override Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + => Task.CompletedTask; + } +} + +// --- 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 is ambiguous without a +// classifier; the write side dispatches by runtime type. +public record Cat(string Name); +public record Dog(string Breed); +public union UnionPet(Cat, Dog); + +// Classifier-disambiguated variant of UnionPet. 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 +{ + 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);