diff --git a/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs index 125a555c9d..654ae9d617 100644 --- a/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs +++ b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs @@ -17,12 +17,8 @@ namespace Microsoft.JSInterop } public sealed partial class DotNetObjectRef : System.IDisposable where TValue : class { - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] - public DotNetObjectRef() { } - [System.Text.Json.Serialization.JsonIgnoreAttribute] + internal DotNetObjectRef() { } public TValue Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] - public long __dotNetObject { get { throw null; } set { } } public void Dispose() { } } public partial interface IJSInProcessRuntime : Microsoft.JSInterop.IJSRuntime diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs index 60b6ad9ab0..3de8f55882 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs @@ -17,7 +17,7 @@ namespace Microsoft.JSInterop /// public static class DotNetDispatcher { - internal const string DotNetObjectRefKey = nameof(DotNetObjectRef.__dotNetObject); + internal static readonly JsonEncodedText DotNetObjectRefKey = JsonEncodedText.Encode("__dotNetObject"); private static readonly Type[] EndInvokeParameterTypes = new Type[] { typeof(long), typeof(bool), typeof(JSAsyncCallResult) }; private static readonly ConcurrentDictionary> _cachedMethodsByAssembly @@ -238,7 +238,7 @@ namespace Microsoft.JSInterop // an incorrect use if there's a object that looks like { '__dotNetObject': }, // but we aren't assigning to DotNetObjectRef{T}. return item.ValueKind == JsonValueKind.Object && - item.TryGetProperty(DotNetObjectRefKey, out _) && + item.TryGetProperty(DotNetObjectRefKey.EncodedUtf8Bytes, out _) && !typeof(IDotNetObjectRef).IsAssignableFrom(parameterType); } } diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRef.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRef.cs index 1aabc5ad59..af790281e9 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRef.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRef.cs @@ -15,7 +15,8 @@ namespace Microsoft.JSInterop /// An instance of . public static DotNetObjectRef Create(TValue value) where TValue : class { - return new DotNetObjectRef(value); + var objectId = DotNetObjectRefManager.Current.TrackObject(value); + return new DotNetObjectRef(objectId, value); } } } diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefJsonConverterFactory.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefJsonConverterFactory.cs new file mode 100644 index 0000000000..5cfec5be9d --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefJsonConverterFactory.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop +{ + internal sealed class DotNetObjectReferenceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(DotNetObjectRef<>); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions jsonSerializerOptions) + { + // System.Text.Json handles caching the converters per type on our behalf. No caching is required here. + var instanceType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(DotNetObjectReferenceJsonConverter<>).MakeGenericType(instanceType); + + return (JsonConverter)Activator.CreateInstance(converterType); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefManager.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefManager.cs index f263716f53..ad1469e38f 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefManager.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefManager.cs @@ -10,7 +10,7 @@ namespace Microsoft.JSInterop internal class DotNetObjectRefManager { private long _nextId = 0; // 0 signals no object, but we increment prior to assignment. The first tracked object should have id 1 - private readonly ConcurrentDictionary _trackedRefsById = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _trackedRefsById = new ConcurrentDictionary(); public static DotNetObjectRefManager Current { @@ -25,7 +25,7 @@ namespace Microsoft.JSInterop } } - public long TrackObject(IDotNetObjectRef dotNetObjectRef) + public long TrackObject(object dotNetObjectRef) { var dotNetObjectId = Interlocked.Increment(ref _nextId); _trackedRefsById[dotNetObjectId] = dotNetObjectRef; @@ -36,7 +36,7 @@ namespace Microsoft.JSInterop public object FindDotNetObject(long dotNetObjectId) { return _trackedRefsById.TryGetValue(dotNetObjectId, out var dotNetObjectRef) - ? dotNetObjectRef.Value + ? dotNetObjectRef : throw new ArgumentException($"There is no tracked object with id '{dotNetObjectId}'. Perhaps the DotNetObjectRef instance was already disposed.", nameof(dotNetObjectId)); } diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefOfT.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefOfT.cs index 8b7035e957..be6bf91663 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefOfT.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefOfT.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.ComponentModel; using System.Text.Json.Serialization; namespace Microsoft.JSInterop @@ -14,54 +13,26 @@ namespace Microsoft.JSInterop /// To avoid leaking memory, the reference must later be disposed by JS code or by .NET code. /// /// The type of the value to wrap. + [JsonConverter(typeof(DotNetObjectReferenceJsonConverterFactory))] public sealed class DotNetObjectRef : IDotNetObjectRef, IDisposable where TValue : class { - private long? _trackingId; - - /// - /// This API is for meant for JSON deserialization and should not be used by user code. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public DotNetObjectRef() - { - } - /// /// Initializes a new instance of . /// + /// The object Id. /// The value to pass by reference. - internal DotNetObjectRef(TValue value) + internal DotNetObjectRef(long objectId, TValue value) { + ObjectId = objectId; Value = value; - _trackingId = DotNetObjectRefManager.Current.TrackObject(this); } /// /// Gets the object instance represented by this wrapper. /// - [JsonIgnore] - public TValue Value { get; private set; } + public TValue Value { get; } - /// - /// This API is for meant for JSON serialization and should not be used by user code. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public long __dotNetObject - { - get => _trackingId.Value; - set - { - if (_trackingId != null) - { - throw new InvalidOperationException($"{nameof(DotNetObjectRef)} cannot be reinitialized."); - } - - _trackingId = value; - Value = (TValue)DotNetObjectRefManager.Current.FindDotNetObject(value); - } - } - - object IDotNetObjectRef.Value => Value; + internal long ObjectId { get; } /// /// Stops tracking this object reference, allowing it to be garbage collected @@ -70,7 +41,7 @@ namespace Microsoft.JSInterop /// public void Dispose() { - DotNetObjectRefManager.Current.ReleaseDotNetObject(_trackingId.Value); + DotNetObjectRefManager.Current.ReleaseDotNetObject(ObjectId); } } } diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceJsonConverter.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceJsonConverter.cs new file mode 100644 index 0000000000..eaabdbf9e6 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceJsonConverter.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop +{ + internal sealed class DotNetObjectReferenceJsonConverter : JsonConverter> where TValue : class + { + private static JsonEncodedText DotNetObjectRefKey => DotNetDispatcher.DotNetObjectRefKey; + + public override DotNetObjectRef Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + long dotNetObjectId = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + if (dotNetObjectId == 0 && reader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes)) + { + reader.Read(); + dotNetObjectId = reader.GetInt64(); + } + else + { + throw new JsonException($"Unexcepted JSON property {reader.GetString()}."); + } + } + else + { + throw new JsonException($"Unexcepted JSON Token {reader.TokenType}."); + } + } + + if (dotNetObjectId is 0) + { + throw new JsonException($"Required property {DotNetObjectRefKey} not found."); + } + + var value = (TValue)DotNetObjectRefManager.Current.FindDotNetObject(dotNetObjectId); + return new DotNetObjectRef(dotNetObjectId, value); + } + + public override void Write(Utf8JsonWriter writer, DotNetObjectRef value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber(DotNetObjectRefKey, value.ObjectId); + writer.WriteEndObject(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/IDotNetObjectRef.cs b/src/JSInterop/Microsoft.JSInterop/src/IDotNetObjectRef.cs index 5f21808a9f..b082d0ce10 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/IDotNetObjectRef.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/IDotNetObjectRef.cs @@ -7,6 +7,5 @@ namespace Microsoft.JSInterop { internal interface IDotNetObjectRef : IDisposable { - public object Value { get; } } } diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs index a4a0de5a3f..1c73cc63b2 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs @@ -142,7 +142,7 @@ namespace Microsoft.JSInterop.Tests Assert.False(resultDto2Ref.TryGetProperty(nameof(TestDTO.StringVal), out _)); Assert.False(resultDto2Ref.TryGetProperty(nameof(TestDTO.IntVal), out _)); - Assert.True(resultDto2Ref.TryGetProperty(DotNetDispatcher.DotNetObjectRefKey, out var property)); + Assert.True(resultDto2Ref.TryGetProperty(DotNetDispatcher.DotNetObjectRefKey.EncodedUtf8Bytes, out var property)); var resultDto2 = Assert.IsType(DotNetObjectRefManager.Current.FindDotNetObject(property.GetInt64())); Assert.Equal("MY STRING", resultDto2.StringVal); Assert.Equal(1299, resultDto2.IntVal); diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectRefTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectRefTest.cs index 77af280d94..112363869e 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectRefTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectRefTest.cs @@ -4,8 +4,9 @@ using System; using System.Threading.Tasks; using Xunit; +using static Microsoft.JSInterop.TestJSRuntime; -namespace Microsoft.JSInterop.Tests +namespace Microsoft.JSInterop { public class DotNetObjectRefTest { @@ -21,38 +22,13 @@ namespace Microsoft.JSInterop.Tests { // Arrange var objRef = DotNetObjectRef.Create(new object()); - var trackingId = objRef.__dotNetObject; // Act objRef.Dispose(); // Assert - var ex = Assert.Throws(() => jsRuntime.ObjectRefManager.FindDotNetObject(trackingId)); + var ex = Assert.Throws(() => jsRuntime.ObjectRefManager.FindDotNetObject(objRef.ObjectId)); Assert.StartsWith("There is no tracked object with id '1'.", ex.Message); }); - - class TestJSRuntime : JSRuntimeBase - { - protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) - { - throw new NotImplementedException(); - } - - protected internal override void EndInvokeDotNet(string callId, bool success, object resultOrError, string assemblyName, string methodIdentifier, long dotNetObjectId) - { - throw new NotImplementedException(); - } - } - - async Task WithJSRuntime(Action testCode) - { - // Since the tests rely on the asynclocal JSRuntime.Current, ensure we - // are on a distinct async context with a non-null JSRuntime.Current - await Task.Yield(); - - var runtime = new TestJSRuntime(); - JSRuntime.SetCurrentJSRuntime(runtime); - testCode(runtime); - } } } diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs new file mode 100644 index 0000000000..18f3db55c1 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs @@ -0,0 +1,149 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; +using static Microsoft.JSInterop.TestJSRuntime; + +namespace Microsoft.JSInterop.Tests +{ + public class DotNetObjectReferenceJsonConverterTest + { + [Fact] + public Task Read_Throws_IfJsonIsMissingDotNetObjectProperty() => WithJSRuntime(_ => + { + // Arrange + var dotNetObjectRef = DotNetObjectRef.Create(new TestModel()); + + var json = "{}"; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize>(json)); + Assert.Equal("Required property __dotNetObject not found.", ex.Message); + }); + + [Fact] + public Task Read_Throws_IfJsonContainsUnknownContent() => WithJSRuntime(_ => + { + // Arrange + var dotNetObjectRef = DotNetObjectRef.Create(new TestModel()); + + var json = "{\"foo\":2}"; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize>(json)); + Assert.Equal("Unexcepted JSON property foo.", ex.Message); + }); + + [Fact] + public Task Read_Throws_IfJsonIsIncomplete() => WithJSRuntime(_ => + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectRef.Create(input); + var objectId = dotNetObjectRef.ObjectId; + + var json = $"{{\"__dotNetObject\":{objectId}"; + + // Act & Assert + var ex = Record.Exception(() => JsonSerializer.Deserialize>(json)); + Assert.IsAssignableFrom(ex); + }); + + [Fact] + public Task Read_Throws_IfDotNetObjectIdAppearsMultipleTimes() => WithJSRuntime(_ => + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectRef.Create(input); + var objectId = dotNetObjectRef.ObjectId; + + var json = $"{{\"__dotNetObject\":{objectId},\"__dotNetObject\":{objectId}}}"; + + // Act & Assert + var ex = Record.Exception(() => JsonSerializer.Deserialize>(json)); + Assert.IsAssignableFrom(ex); + }); + + [Fact] + public Task Read_ReadsJson() => WithJSRuntime(_ => + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectRef.Create(input); + var objectId = dotNetObjectRef.ObjectId; + + var json = $"{{\"__dotNetObject\":{objectId}}}"; + + // Act + var deserialized = JsonSerializer.Deserialize>(json); + + // Assert + Assert.Same(input, deserialized.Value); + Assert.Equal(objectId, deserialized.ObjectId); + }); + + + [Fact] + public Task Read_ReturnsTheCorrectInstance() => WithJSRuntime(_ => + { + // Arrange + // Track a few instances and verify that the deserialized value returns the correct value. + var instance1 = new TestModel(); + var instance2 = new TestModel(); + var ref1 = DotNetObjectRef.Create(instance1); + var ref2 = DotNetObjectRef.Create(instance2); + + var json = $"[{{\"__dotNetObject\":{ref2.ObjectId}}},{{\"__dotNetObject\":{ref1.ObjectId}}}]"; + + // Act + var deserialized = JsonSerializer.Deserialize[]>(json); + + // Assert + Assert.Same(instance2, deserialized[0].Value); + Assert.Same(instance1, deserialized[1].Value); + }); + + [Fact] + public Task Read_ReadsJson_WithFormatting() => WithJSRuntime(_ => + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectRef.Create(input); + var objectId = dotNetObjectRef.ObjectId; + + var json = +@$"{{ + ""__dotNetObject"": {objectId} +}}"; + + // Act + var deserialized = JsonSerializer.Deserialize>(json); + + // Assert + Assert.Same(input, deserialized.Value); + Assert.Equal(objectId, deserialized.ObjectId); + }); + + [Fact] + public Task WriteJsonTwice_KeepsObjectId() => WithJSRuntime(_ => + { + // Arrange + var dotNetObjectRef = DotNetObjectRef.Create(new TestModel()); + + // Act + var json1 = JsonSerializer.Serialize(dotNetObjectRef); + var json2 = JsonSerializer.Serialize(dotNetObjectRef); + + // Assert + Assert.Equal($"{{\"__dotNetObject\":{dotNetObjectRef.ObjectId}}}", json1); + Assert.Equal(json1, json2); + }); + + private class TestModel + { + + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs new file mode 100644 index 0000000000..c4e6b05c5b --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.JSInterop +{ + internal class TestJSRuntime : JSRuntimeBase + { + protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) + { + throw new NotImplementedException(); + } + + protected internal override void EndInvokeDotNet(string callId, bool success, object resultOrError, string assemblyName, string methodIdentifier, long dotNetObjectId) + { + throw new NotImplementedException(); + } + + public static async Task WithJSRuntime(Action testCode) + { + // Since the tests rely on the asynclocal JSRuntime.Current, ensure we + // are on a distinct async context with a non-null JSRuntime.Current + await Task.Yield(); + + var runtime = new TestJSRuntime(); + JSRuntime.SetCurrentJSRuntime(runtime); + testCode(runtime); + } + } +}