From a08a418a97604e0e8ae7fb81ba802654b7c8ddfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:25:17 +0000 Subject: [PATCH 1/3] Initial plan From c4d1ab2c1b0ebb7688d37b2e8e59428381d83e5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:44:18 +0000 Subject: [PATCH 2/3] Implement TypeJsonConverter to fix DynamicComponent serialization issue Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/Infrastructure/DotNetDispatcher.cs | 1 + .../src/Infrastructure/TypeJsonConverter.cs | 162 +++++++++++++++ .../Microsoft.JSInterop/src/JSRuntime.cs | 1 + .../Infrastructure/TypeJsonConverterTest.cs | 188 ++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TypeJsonConverter.cs create mode 100644 src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs index 0624ee6a8457..9b3321f1e777 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs @@ -517,6 +517,7 @@ public static void ClearCache(Type[]? _) _cachedMethodsByAssembly.Clear(); _cachedMethodsByType.Clear(); _cachedConvertToTaskByType.Clear(); + TypeJsonConverter.ClearCache(); } } diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TypeJsonConverter.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TypeJsonConverter.cs new file mode 100644 index 000000000000..7d31d073e013 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TypeJsonConverter.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop.Infrastructure; + +/// +/// A JsonConverter for System.Type that serializes types as assembly name and type name, +/// and deserializes by loading the type from the appropriate assembly. +/// This converter maintains a cache to avoid repeated lookups. +/// +internal sealed class TypeJsonConverter : JsonConverter +{ + private static readonly ConcurrentDictionary _typeCache = new(); + + public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException($"Expected StartObject, got {reader.TokenType}"); + } + + string? assemblyName = null; + string? typeName = null; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "assembly": + assemblyName = reader.GetString(); + break; + case "type": + typeName = reader.GetString(); + break; + default: + throw new JsonException($"Unexpected property '{propertyName}' in Type JSON."); + } + } + } + + if (string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(typeName)) + { + throw new JsonException("Type JSON must contain both 'assembly' and 'type' properties."); + } + + var typeKey = new TypeKey(assemblyName, typeName); + return _typeCache.GetOrAdd(typeKey, ResolveType); + } + + public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + var assemblyName = value.Assembly.GetName().Name; + if (string.IsNullOrEmpty(assemblyName)) + { + throw new InvalidOperationException("Cannot serialize type from assembly with null name."); + } + + var typeName = value.FullName; + if (string.IsNullOrEmpty(typeName)) + { + throw new InvalidOperationException("Cannot serialize type with null FullName."); + } + + writer.WriteStartObject(); + writer.WriteString("assembly", assemblyName); + writer.WriteString("type", typeName); + writer.WriteEndObject(); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Types used with JSInterop are expected to be in assemblies that don't get trimmed.")] + private static Type? ResolveType(TypeKey typeKey) + { + // First try to find the assembly among already loaded assemblies + Assembly? assembly = null; + foreach (var loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (loadedAssembly.GetName().Name == typeKey.AssemblyName) + { + assembly = loadedAssembly; + break; + } + } + + if (assembly is null) + { + // If assembly is not loaded yet, try to load it by name + try + { + assembly = Assembly.Load(typeKey.AssemblyName); + } + catch + { + throw new InvalidOperationException($"Cannot load assembly '{typeKey.AssemblyName}' for type deserialization."); + } + } + + var type = assembly.GetType(typeKey.TypeName); + if (type is null) + { + throw new InvalidOperationException($"Cannot find type '{typeKey.TypeName}' in assembly '{typeKey.AssemblyName}'."); + } + + return type; + } + + internal static void ClearCache() + { + _typeCache.Clear(); + } + + private readonly struct TypeKey : IEquatable + { + public TypeKey(string assemblyName, string typeName) + { + AssemblyName = assemblyName; + TypeName = typeName; + } + + public string AssemblyName { get; } + public string TypeName { get; } + + public bool Equals(TypeKey other) + { + return AssemblyName.Equals(other.AssemblyName, StringComparison.Ordinal) && + TypeName.Equals(other.TypeName, StringComparison.Ordinal); + } + + public override bool Equals(object? obj) + { + return obj is TypeKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine( + StringComparer.Ordinal.GetHashCode(AssemblyName), + StringComparer.Ordinal.GetHashCode(TypeName)); + } + } +} \ No newline at end of file diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs index 53400106f4d1..8384aaf4ff33 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs @@ -42,6 +42,7 @@ protected JSRuntime() new JSStreamReferenceJsonConverter(this), new DotNetStreamReferenceJsonConverter(this), new ByteArrayJsonConverter(this), + new TypeJsonConverter(), } }; } diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs new file mode 100644 index 000000000000..9fa9950b59fe --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs @@ -0,0 +1,188 @@ +// 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.JSInterop.Infrastructure; + +public class TypeJsonConverterTest +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public TypeJsonConverterTest() + { + _jsonSerializerOptions = new JsonSerializerOptions(); + _jsonSerializerOptions.Converters.Add(new TypeJsonConverter()); + } + + [Fact] + public void CanSerializeAndDeserializeType() + { + // Arrange + var originalType = typeof(string); + + // Act + var json = JsonSerializer.Serialize(originalType, _jsonSerializerOptions); + var deserializedType = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert + Assert.Equal(originalType, deserializedType); + } + + [Fact] + public void CanSerializeAndDeserializeGenericType() + { + // Arrange + var originalType = typeof(List); + + // Act + var json = JsonSerializer.Serialize(originalType, _jsonSerializerOptions); + var deserializedType = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert + Assert.Equal(originalType, deserializedType); + } + + [Fact] + public void CanSerializeAndDeserializeNullType() + { + // Arrange + Type? originalType = null; + + // Act + var json = JsonSerializer.Serialize(originalType, _jsonSerializerOptions); + var deserializedType = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert + Assert.Null(deserializedType); + } + + [Fact] + public void SerializedTypeContainsAssemblyAndTypeProperties() + { + // Arrange + var type = typeof(TypeJsonConverterTest); + + // Act + var json = JsonSerializer.Serialize(type, _jsonSerializerOptions); + + // Assert + var jsonDocument = JsonDocument.Parse(json); + var root = jsonDocument.RootElement; + + Assert.True(root.TryGetProperty("assembly", out var assemblyProperty)); + Assert.True(root.TryGetProperty("type", out var typeProperty)); + + Assert.Equal("Microsoft.JSInterop.Tests", assemblyProperty.GetString()); + Assert.Equal("Microsoft.JSInterop.Infrastructure.TypeJsonConverterTest", typeProperty.GetString()); + } + + [Fact] + public void Read_ThrowsJsonException_IfJsonIsMissingAssemblyProperty() + { + // Arrange + var json = """{"type":"System.String"}"""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.Equal("Type JSON must contain both 'assembly' and 'type' properties.", ex.Message); + } + + [Fact] + public void Read_ThrowsJsonException_IfJsonIsMissingTypeProperty() + { + // Arrange + var json = """{"assembly":"System.Private.CoreLib"}"""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.Equal("Type JSON must contain both 'assembly' and 'type' properties.", ex.Message); + } + + [Fact] + public void Read_ThrowsJsonException_IfJsonContainsUnknownProperty() + { + // Arrange + var json = """{"assembly":"System.Private.CoreLib","type":"System.String","unknown":"value"}"""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.Equal("Unexpected property 'unknown' in Type JSON.", ex.Message); + } + + [Fact] + public void Read_ThrowsInvalidOperationException_IfAssemblyCannotBeLoaded() + { + // Arrange + var json = """{"assembly":"NonExistentAssembly","type":"SomeType"}"""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.StartsWith("Cannot load assembly 'NonExistentAssembly'", ex.Message); + } + + [Fact] + public void Read_ThrowsInvalidOperationException_IfTypeCannotBeFound() + { + // Arrange + var json = """{"assembly":"System.Private.CoreLib","type":"NonExistentType"}"""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.StartsWith("Cannot find type 'NonExistentType' in assembly 'System.Private.CoreLib'", ex.Message); + } + + [Fact] + public void Read_ThrowsJsonException_IfJsonIsNotStartObject() + { + // Arrange + var json = """"invalid""""; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions)); + Assert.Equal("Expected StartObject, got String", ex.Message); + } + + [Fact] + public void Write_ThrowsInvalidOperationException_IfAssemblyNameIsNull() + { + // This is a theoretical test since we can't easily create a Type with null assembly name + // but it documents the expected behavior + Assert.True(true); // Placeholder - difficult to test this edge case + } + + [Fact] + public void CacheWorks_WithMultipleRequests() + { + // Arrange + var type = typeof(string); + var json = JsonSerializer.Serialize(type, _jsonSerializerOptions); + + // Act - Deserialize the same type multiple times + var type1 = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + var type2 = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Assert - Should return the same type instance + Assert.Equal(type, type1); + Assert.Equal(type, type2); + Assert.Same(type1, type2); // Same reference due to caching + } + + [Fact] + public void ClearCache_RemovesCachedEntries() + { + // Arrange + var type = typeof(string); + var json = JsonSerializer.Serialize(type, _jsonSerializerOptions); + + // First deserialization to populate cache + JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + // Act + TypeJsonConverter.ClearCache(); + + // Assert - Should still work after cache clear + var deserializedType = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + Assert.Equal(type, deserializedType); + } +} \ No newline at end of file From f4bdbe4644817beb61d1f7b3b12bbc84b1f59df7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:16:08 +0000 Subject: [PATCH 3/3] Fix TypeJsonConverterTest: correct JSON string format in test Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../test/Infrastructure/TypeJsonConverterTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs index 9fa9950b59fe..32f170f50af4 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/TypeJsonConverterTest.cs @@ -136,7 +136,7 @@ public void Read_ThrowsInvalidOperationException_IfTypeCannotBeFound() public void Read_ThrowsJsonException_IfJsonIsNotStartObject() { // Arrange - var json = """"invalid""""; + var json = "\"invalid\""; // Act & Assert var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, _jsonSerializerOptions));