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..3bc1f83c7e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectRefJsonConverterFactory.cs @@ -0,0 +1,29 @@ +// 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.Diagnostics; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop +{ + internal sealed class DotNetObjectReferenceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + Debug.Assert( + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(DotNetObjectRef<>), + "We expect this to only be called for DotNetObjectRef instances."); + return true; + } + + protected override JsonConverter CreateConverter(Type typeToConvert) + { + // 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..d07735734e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceJsonConverter.cs @@ -0,0 +1,51 @@ +// 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.IO; +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) + { + if (!reader.Read()) + { + throw new InvalidDataException("Invalid DotNetObjectRef JSON."); + } + + if (reader.TokenType != JsonTokenType.PropertyName || !reader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes)) + { + throw new InvalidDataException("Invalid DotNetObjectRef JSON."); + } + + if (!reader.Read()) + { + throw new InvalidDataException("Invalid DotNetObjectRef JSON."); + } + + var dotNetObjectId = reader.GetInt64(); + + if (!reader.Read()) + { + // We need to read all the data that was given to us. + throw new InvalidDataException("Invalid DotNetObjectRef JSON."); + } + + 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/Class1.cs b/src/JSInterop/Microsoft.JSInterop/test/Class1.cs new file mode 100644 index 0000000000..1e58139b36 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Class1.cs @@ -0,0 +1,27 @@ +// 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(); + } + + 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); + } + } +} 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..f417985746 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,21 +22,14 @@ 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) diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs new file mode 100644 index 0000000000..c2dbd88895 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceJsonConverterTest.cs @@ -0,0 +1,78 @@ +// 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_ReadsJson() => WithJSRuntime(_ => + { + // Arrange + // Throw-away value + DotNetObjectRef.Create(new TestModel()); + 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_ReadsJson_WithFormatting() => WithJSRuntime(_ => + { + // Arrange + // Throw-away value + DotNetObjectRef.Create(new TestModel()); + 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 + // Throw-away value + DotNetObjectRef.Create(new TestModel()); + 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 + { + + } + } +}