Parcourir la source

Use Utf8JsonReader in DotNetDispatcher (dotnet/extensions#2061)

* Use Utf8JsonReader in DotNetDispatcher

Fixes https://github.com/aspnet/AspNetCore/issues/10988
\n\nCommit migrated from https://github.com/dotnet/extensions/commit/c24711c84defc8dfbd496280c2973eab0e88cbcf
Pranav K il y a 6 ans
Parent
commit
557bd8e011

+ 79 - 68
src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs

@@ -7,6 +7,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Reflection;
 using System.Runtime.ExceptionServices;
+using System.Text;
 using System.Text.Json;
 using System.Threading.Tasks;
 
@@ -18,7 +19,6 @@ namespace Microsoft.JSInterop
     public static class DotNetDispatcher
     {
         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<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
             = new ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
@@ -74,7 +74,6 @@ namespace Microsoft.JSInterop
             // code has to implement its own way of returning async results.
             var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current;
 
-
             // Using ExceptionDispatchInfo here throughout because we want to always preserve
             // original stack traces.
             object syncResult = null;
@@ -165,81 +164,64 @@ namespace Microsoft.JSInterop
             }
         }
 
-        private static object[] ParseArguments(string methodIdentifier, string argsJson, Type[] parameterTypes)
+        internal static object[] ParseArguments(string methodIdentifier, string arguments, Type[] parameterTypes)
         {
             if (parameterTypes.Length == 0)
             {
                 return Array.Empty<object>();
             }
 
-            // There's no direct way to say we want to deserialize as an array with heterogenous
-            // entry types (e.g., [string, int, bool]), so we need to deserialize in two phases.
-            var jsonDocument = JsonDocument.Parse(argsJson);
-            var shouldDisposeJsonDocument = true;
-            try
+            var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
+            var reader = new Utf8JsonReader(utf8JsonBytes);
+            if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
             {
-                if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array)
-                {
-                    throw new ArgumentException($"Expected a JSON array but got {jsonDocument.RootElement.ValueKind}.");
-                }
+                throw new JsonException("Invalid JSON");
+            }
 
-                var suppliedArgsLength = jsonDocument.RootElement.GetArrayLength();
+            var suppliedArgs = new object[parameterTypes.Length];
 
-                if (suppliedArgsLength != parameterTypes.Length)
+            var index = 0;
+            while (index < parameterTypes.Length && reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+            {
+                var parameterType = parameterTypes[index];
+                if (reader.TokenType == JsonTokenType.StartObject && IsIncorrectDotNetObjectRefUse(parameterType, reader))
                 {
-                    throw new ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}.");
+                    throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
                 }
 
-                // Second, convert each supplied value to the type expected by the method
-                var suppliedArgs = new object[parameterTypes.Length];
-                var index = 0;
-                foreach (var item in jsonDocument.RootElement.EnumerateArray())
-                {
-                    var parameterType = parameterTypes[index];
-
-                    if (parameterType == typeof(JSAsyncCallResult))
-                    {
-                        // We will pass the JsonDocument instance to JAsyncCallResult and make JSRuntimeBase
-                        // responsible for disposing it.
-                        shouldDisposeJsonDocument = false;
-                        // For JS async call results, we have to defer the deserialization until
-                        // later when we know what type it's meant to be deserialized as
-                        suppliedArgs[index] = new JSAsyncCallResult(jsonDocument, item);
-                    }
-                    else if (IsIncorrectDotNetObjectRefUse(item, parameterType))
-                    {
-                        throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
-                    }
-                    else
-                    {
-                        suppliedArgs[index] = JsonSerializer.Deserialize(item.GetRawText(), parameterType, JsonSerializerOptionsProvider.Options);
-                    }
-
-                    index++;
-                }
-
-                if (shouldDisposeJsonDocument)
-                {
-                    jsonDocument.Dispose();
-                }
+                suppliedArgs[index] = JsonSerializer.Deserialize(ref reader, parameterType, JsonSerializerOptionsProvider.Options);
+                index++;
+            }
 
-                return suppliedArgs;
+            if (index < parameterTypes.Length)
+            {
+                // If we parsed fewer parameters, we can always make a definitive claim about how many parameters were received.
+                throw new ArgumentException($"The call to '{methodIdentifier}' expects '{parameterTypes.Length}' parameters, but received '{index}'.");
             }
-            catch
+
+            if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
             {
-                // Always dispose the JsonDocument in case of an error.
-                jsonDocument?.Dispose();
-                throw;
+                // Either we received more parameters than we expected or the JSON is malformed.
+                throw new JsonException($"Unexpected JSON token {reader.TokenType}. Ensure that the call to `{methodIdentifier}' is supplied with exactly '{parameterTypes.Length}' parameters.");
             }
 
-            static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType)
+            return suppliedArgs;
+
+            // Note that the JsonReader instance is intentionally not passed by ref (or an in parameter) since we want a copy of the original reader.
+            static bool IsIncorrectDotNetObjectRefUse(Type parameterType, Utf8JsonReader jsonReader)
             {
                 // Check for incorrect use of DotNetObjectRef<T> at the top level. We know it's
                 // an incorrect use if there's a object that looks like { '__dotNetObject': <some number> },
                 // but we aren't assigning to DotNetObjectRef{T}.
-                return item.ValueKind == JsonValueKind.Object &&
-                    item.TryGetProperty(DotNetObjectRefKey.EncodedUtf8Bytes, out _) &&
-                     !typeof(IDotNetObjectRef).IsAssignableFrom(parameterType);
+                if (jsonReader.Read() &&
+                    jsonReader.TokenType == JsonTokenType.PropertyName &&
+                    jsonReader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes))
+                {
+                    // The JSON payload has the shape we expect from a DotNetObjectRef instance.
+                    return !parameterType.IsGenericType || parameterType.GetGenericTypeDefinition() != typeof(DotNetObjectRef<>);
+                }
+
+                return false;
             }
         }
 
@@ -248,9 +230,9 @@ namespace Microsoft.JSInterop
         /// associated <see cref="Task"/> as completed.
         /// </summary>
         /// <remarks>
-        /// All exceptions from <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> are caught
+        /// All exceptions from <see cref="EndInvoke"/> are caught
         /// are delivered via JS interop to the JavaScript side when it requests confirmation, as
-        /// the mechanism to call <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> relies on
+        /// the mechanism to call <see cref="EndInvoke"/> relies on
         /// using JS->.NET interop. This overload is meant for directly triggering completion callbacks
         /// for .NET -> JS operations without going through JS interop, so the callsite for this
         /// method is responsible for handling any possible exception generated from the arguments
@@ -263,16 +245,40 @@ namespace Microsoft.JSInterop
         /// </exception>
         public static void EndInvoke(string arguments)
         {
-            var parsedArgs = ParseArguments(
-                nameof(EndInvoke),
-                arguments,
-                EndInvokeParameterTypes);
-
-            EndInvoke((long)parsedArgs[0], (bool)parsedArgs[1], (JSAsyncCallResult)parsedArgs[2]);
+            var jsRuntimeBase = (JSRuntimeBase)JSRuntime.Current;
+            ParseEndInvokeArguments(jsRuntimeBase, arguments);
         }
 
-        private static void EndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult result)
-            => ((JSRuntimeBase)JSRuntime.Current).EndInvokeJS(asyncHandle, succeeded, result);
+        internal static void ParseEndInvokeArguments(JSRuntimeBase jsRuntimeBase, string arguments)
+        {
+            var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
+
+            // The payload that we're trying to parse is of the format
+            // [ taskId: long, success: boolean, value: string? | object ]
+            // where value is the .NET type T originally specified on InvokeAsync<T> or the error string if success is false.
+            // We parse the first two arguments and call in to JSRuntimeBase to deserialize the actual value.
+
+            var reader = new Utf8JsonReader(utf8JsonBytes);
+
+            if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
+            {
+                throw new JsonException("Invalid JSON");
+            }
+
+            reader.Read();
+            var taskId = reader.GetInt64();
+
+            reader.Read();
+            var success = reader.GetBoolean();
+
+            reader.Read();
+            jsRuntimeBase.EndInvokeJS(taskId, success, ref reader);
+
+            if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
+            {
+                throw new JsonException("Invalid JSON");
+            }
+        }
 
         /// <summary>
         /// Releases the reference to the specified .NET object. This allows the .NET runtime
@@ -362,7 +368,13 @@ namespace Microsoft.JSInterop
             // In some edge cases this might force developers to explicitly call something on the
             // target assembly (from .NET) before they can invoke its allowed methods from JS.
             var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
-            return loadedAssemblies.FirstOrDefault(a => new AssemblyKey(a).Equals(assemblyKey))
+
+            // Using LastOrDefault to workaround for https://github.com/dotnet/arcade/issues/2816.
+            // In most ordinary scenarios, we wouldn't have two instances of the same Assembly in the AppDomain
+            // so this doesn't change the outcome.
+            var assembly = loadedAssemblies.LastOrDefault(a => new AssemblyKey(a).Equals(assemblyKey));
+
+            return assembly
                 ?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyKey.AssemblyName}'.");
         }
 
@@ -396,6 +408,5 @@ namespace Microsoft.JSInterop
 
             public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName);
         }
-
     }
 }

+ 0 - 36
src/JSInterop/Microsoft.JSInterop/src/JSAsyncCallResult.cs

@@ -1,36 +0,0 @@
-// 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;
-
-namespace Microsoft.JSInterop
-{
-    // This type takes care of a special case in handling the result of an async call from
-    // .NET to JS. The information about what type the result should be exists only on the
-    // corresponding TaskCompletionSource<T>. We don't have that information at the time
-    // that we deserialize the incoming argsJson before calling DotNetDispatcher.EndInvoke.
-    // Declaring the EndInvoke parameter type as JSAsyncCallResult defers the deserialization
-    // until later when we have access to the TaskCompletionSource<T>.
-    //
-    // There's no reason why developers would need anything similar to this in user code,
-    // because this is the mechanism by which we resolve the incoming argsJson to the correct
-    // user types before completing calls.
-    //
-    // It's marked as 'public' only because it has to be for use as an argument on a
-    // [JSInvokable] method.
-
-    /// <summary>
-    /// Intended for framework use only.
-    /// </summary>
-    internal sealed class JSAsyncCallResult
-    {
-        internal JSAsyncCallResult(JsonDocument document, JsonElement jsonElement)
-        {
-            JsonDocument = document;
-            JsonElement = jsonElement;
-        }
-
-        internal JsonElement JsonElement { get; }
-        internal JsonDocument JsonDocument { get; }
-    }
-}

+ 18 - 23
src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs

@@ -5,7 +5,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Linq;
-using System.Runtime.ExceptionServices;
 using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
@@ -139,41 +138,37 @@ namespace Microsoft.JSInterop
             string methodIdentifier,
             long dotNetObjectId);
 
-        internal void EndInvokeJS(long taskId, bool succeeded, JSAsyncCallResult asyncCallResult)
+        internal void EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonReader)
         {
-            using (asyncCallResult?.JsonDocument)
+            if (!_pendingTasks.TryRemove(taskId, out var tcs))
             {
-                if (!_pendingTasks.TryRemove(taskId, out var tcs))
-                {
-                    // We should simply return if we can't find an id for the invocation.
-                    // This likely means that the method that initiated the call defined a timeout and stopped waiting.
-                    return;
-                }
+                // We should simply return if we can't find an id for the invocation.
+                // This likely means that the method that initiated the call defined a timeout and stopped waiting.
+                return;
+            }
 
-                CleanupTasksAndRegistrations(taskId);
+            CleanupTasksAndRegistrations(taskId);
 
+            try
+            {
                 if (succeeded)
                 {
                     var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);
-                    try
-                    {
-                        var result = asyncCallResult != null ?
-                            JsonSerializer.Deserialize(asyncCallResult.JsonElement.GetRawText(), resultType, JsonSerializerOptionsProvider.Options) :
-                            null;
-                        TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
-                    }
-                    catch (Exception exception)
-                    {
-                        var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
-                        TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
-                    }
+
+                    var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptionsProvider.Options);
+                    TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
                 }
                 else
                 {
-                    var exceptionText = asyncCallResult?.JsonElement.ToString() ?? string.Empty;
+                    var exceptionText = jsonReader.GetString() ?? string.Empty;
                     TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText));
                 }
             }
+            catch (Exception exception)
+            {
+                var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
+                TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
+            }
         }
     }
 }

+ 243 - 8
src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs

@@ -2,15 +2,14 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
-using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Runtime.ExceptionServices;
 using System.Text.Json;
-using System.Text.Json.Serialization;
+using System.Threading;
 using System.Threading.Tasks;
 using Xunit;
 
-namespace Microsoft.JSInterop.Tests
+namespace Microsoft.JSInterop
 {
     public class DotNetDispatcherTest
     {
@@ -239,6 +238,72 @@ namespace Microsoft.JSInterop.Tests
             Assert.StartsWith("There is no tracked object with id '1'.", ex.Message);
         });
 
+        [Fact]
+        public Task EndInvoke_WithSuccessValue() => WithJSRuntime(jsRuntime =>
+        {
+            // Arrange
+            var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 };
+            var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
+            var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, JsonSerializerOptionsProvider.Options);
+
+            // Act
+            DotNetDispatcher.EndInvoke(argsJson);
+
+            // Assert
+            Assert.True(task.IsCompletedSuccessfully);
+            var result = task.Result;
+            Assert.Equal(testDTO.StringVal, result.StringVal);
+            Assert.Equal(testDTO.IntVal, result.IntVal);
+        });
+
+        [Fact]
+        public Task EndInvoke_WithErrorString() => WithJSRuntime(async jsRuntime =>
+        {
+            // Arrange
+            var expected = "Some error";
+            var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
+            var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, expected }, JsonSerializerOptionsProvider.Options);
+
+            // Act
+            DotNetDispatcher.EndInvoke(argsJson);
+
+            // Assert
+            var ex = await Assert.ThrowsAsync<JSException>(() => task);
+            Assert.Equal(expected, ex.Message);
+        });
+
+        [Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12357")]
+        public Task EndInvoke_AfterCancel() => WithJSRuntime(jsRuntime =>
+        {
+            // Arrange
+            var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 };
+            var cts = new CancellationTokenSource();
+            var task = jsRuntime.InvokeAsync<TestDTO>("unimportant", cts.Token);
+            var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, JsonSerializerOptionsProvider.Options);
+
+            // Act
+            cts.Cancel();
+            DotNetDispatcher.EndInvoke(argsJson);
+
+            // Assert
+            Assert.True(task.IsCanceled);
+        });
+
+        [Fact]
+        public Task EndInvoke_WithNullError() => WithJSRuntime(async jsRuntime =>
+        {
+            // Arrange
+            var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
+            var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, null }, JsonSerializerOptionsProvider.Options);
+
+            // Act
+            DotNetDispatcher.EndInvoke(argsJson);
+
+            // Assert
+            var ex = await Assert.ThrowsAsync<JSException>(() => task);
+            Assert.Empty(ex.Message);
+        });
+
         [Fact]
         public Task CanInvokeInstanceMethodWithParams() => WithJSRuntime(jsRuntime =>
         {
@@ -261,10 +326,14 @@ namespace Microsoft.JSInterop.Tests
         });
 
         [Fact]
-        public void CannotInvokeWithIncorrectNumberOfParams()
+        public Task CannotInvokeWithFewerNumberOfParameters() => WithJSRuntime(jsRuntime =>
         {
             // Arrange
-            var argsJson = JsonSerializer.Serialize(new object[] { 1, 2, 3, 4 }, JsonSerializerOptionsProvider.Options);
+            var argsJson = JsonSerializer.Serialize(new object[]
+            {
+                new TestDTO { StringVal = "Another string", IntVal = 456 },
+                new[] { 100, 200 },
+            }, JsonSerializerOptionsProvider.Options);
 
             // Act/Assert
             var ex = Assert.Throws<ArgumentException>(() =>
@@ -272,8 +341,30 @@ namespace Microsoft.JSInterop.Tests
                 DotNetDispatcher.Invoke(thisAssemblyName, "InvocableStaticWithParams", default, argsJson);
             });
 
-            Assert.Equal("In call to 'InvocableStaticWithParams', expected 3 parameters but received 4.", ex.Message);
-        }
+            Assert.Equal("The call to 'InvocableStaticWithParams' expects '3' parameters, but received '2'.", ex.Message);
+        });
+
+        [Fact]
+        public Task CannotInvokeWithMoreParameters() => WithJSRuntime(jsRuntime =>
+        {
+            // Arrange
+            var objectRef = DotNetObjectRef.Create(new TestDTO { IntVal = 4 });
+            var argsJson = JsonSerializer.Serialize(new object[]
+            {
+                new TestDTO { StringVal = "Another string", IntVal = 456 },
+                new[] { 100, 200 },
+                objectRef,
+                7,
+            }, JsonSerializerOptionsProvider.Options);
+
+            // Act/Assert
+            var ex = Assert.Throws<JsonException>(() =>
+            {
+                DotNetDispatcher.Invoke(thisAssemblyName, "InvocableStaticWithParams", default, argsJson);
+            });
+
+            Assert.Equal("Unexpected JSON token Number. Ensure that the call to `InvocableStaticWithParams' is supplied with exactly '3' parameters.", ex.Message);
+        });
 
         [Fact]
         public Task CanInvokeAsyncMethod() => WithJSRuntime(async jsRuntime =>
@@ -301,7 +392,7 @@ namespace Microsoft.JSInterop.Tests
             // Assert: Correct completion information
             Assert.Equal(callId, jsRuntime.LastCompletionCallId);
             Assert.True(jsRuntime.LastCompletionStatus);
-            var result = Assert.IsType<object []>(jsRuntime.LastCompletionResult);
+            var result = Assert.IsType<object[]>(jsRuntime.LastCompletionResult);
             var resultDto1 = Assert.IsType<TestDTO>(result[0]);
 
             Assert.Equal("STRING VIA JSON", resultDto1.StringVal);
@@ -390,6 +481,150 @@ namespace Microsoft.JSInterop.Tests
             Assert.StartsWith("System.ArgumentException: There is no tracked object with id '1'. Perhaps the DotNetObjectRef instance was already disposed.", result.SourceException.ToString());
         });
 
+        [Theory]
+        [InlineData("")]
+        [InlineData("<xml>")]
+        public void ParseArguments_ThrowsIfJsonIsInvalid(string arguments)
+        {
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
+        }
+
+        [Theory]
+        [InlineData("{\"key\":\"value\"}")]
+        [InlineData("\"Test\"")]
+        public void ParseArguments_ThrowsIfTheArgsJsonIsNotArray(string arguments)
+        {
+            // Act & Assert
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
+        }
+
+        [Theory]
+        [InlineData("[\"hello\"")]
+        [InlineData("[\"hello\",")]
+        public void ParseArguments_ThrowsIfTheArgsJsonIsInvalidArray(string arguments)
+        {
+            // Act & Assert
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
+        }
+
+        [Fact]
+        public void ParseArguments_Works()
+        {
+            // Arrange
+            var arguments = "[\"Hello\", 2]";
+
+            // Act
+            var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string), typeof(int), });
+
+            // Assert
+            Assert.Equal(new object[] { "Hello", 2 }, result);
+        }
+
+        [Fact]
+        public void ParseArguments_SingleArgument()
+        {
+            // Arrange
+            var arguments = "[{\"IntVal\": 7}]";
+
+            // Act
+            var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(TestDTO), });
+
+            // Assert
+            var value = Assert.IsType<TestDTO>(Assert.Single(result));
+            Assert.Equal(7, value.IntVal);
+            Assert.Null(value.StringVal);
+        }
+
+        [Fact]
+        public void ParseArguments_NullArgument()
+        {
+            // Arrange
+            var arguments = "[4, null]";
+
+            // Act
+            var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(int), typeof(TestDTO), });
+
+            // Assert
+            Assert.Collection(
+                result,
+                v => Assert.Equal(4, v),
+                v => Assert.Null(v));
+        }
+
+        [Fact]
+        public void ParseArguments_Throws_WithIncorrectDotNetObjectRefUsage()
+        {
+            // Arrange
+            var method = "SomeMethod";
+            var arguments = "[4, {\"__dotNetObject\": 7}]";
+
+            // Act
+            var ex = Assert.Throws<InvalidOperationException>(() => DotNetDispatcher.ParseArguments(method, arguments, new[] { typeof(int), typeof(TestDTO), }));
+
+            // Assert
+            Assert.Equal($"In call to '{method}', parameter of type '{nameof(TestDTO)}' at index 2 must be declared as type 'DotNetObjectRef<TestDTO>' to receive the incoming value.", ex.Message);
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_ThrowsIfJsonIsEmptyString()
+        {
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), ""));
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_ThrowsIfJsonIsNotArray()
+        {
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "{\"key\": \"value\"}"));
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_ThrowsIfJsonArrayIsInComplete()
+        {
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false"));
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_ThrowsIfJsonArrayHasMoreThan3Arguments()
+        {
+            Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false, \"Hello\", 5]"));
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_Works()
+        {
+            var jsRuntime = new TestJSRuntime();
+            var task = jsRuntime.InvokeAsync<TestDTO>("somemethod");
+
+            DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, {{\"intVal\": 7}}]");
+
+            Assert.True(task.IsCompletedSuccessfully);
+            Assert.Equal(7, task.Result.IntVal);
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_WithArrayValue()
+        {
+            var jsRuntime = new TestJSRuntime();
+            var task = jsRuntime.InvokeAsync<int[]>("somemethod");
+
+            DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, [1, 2, 3]]");
+
+            Assert.True(task.IsCompletedSuccessfully);
+            Assert.Equal(new[] { 1, 2, 3 }, task.Result);
+        }
+
+        [Fact]
+        public void ParseEndInvokeArguments_WithNullValue()
+        {
+            var jsRuntime = new TestJSRuntime();
+            var task = jsRuntime.InvokeAsync<TestDTO>("somemethod");
+
+            DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, null]");
+
+            Assert.True(task.IsCompletedSuccessfully);
+            Assert.Null(task.Result);
+        }
+
         Task WithJSRuntime(Action<TestJSRuntime> testCode)
         {
             return WithJSRuntime(jsRuntime =>

+ 90 - 28
src/JSInterop/Microsoft.JSInterop/test/JSRuntimeBaseTest.cs

@@ -4,13 +4,13 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using System.Runtime.ExceptionServices;
+using System.Text;
 using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Xunit;
 
-namespace Microsoft.JSInterop.Tests
+namespace Microsoft.JSInterop
 {
     public class JSRuntimeBaseTest
     {
@@ -54,18 +54,19 @@ namespace Microsoft.JSInterop.Tests
         }
 
         [Fact]
-        public async Task InvokeAsync_CompletesSuccessfullyBeforeTimeout()
+        public void InvokeAsync_CompletesSuccessfullyBeforeTimeout()
         {
             // Arrange
             var runtime = new TestJSRuntime();
             runtime.DefaultTimeout = TimeSpan.FromSeconds(10);
+            var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes("null"));
 
             // Act
             var task = runtime.InvokeAsync<object>("test identifier 1", "arg1", 123, true);
-            runtime.EndInvokeJS(2, succeeded: true, null);
 
-            // Assert
-            await task;
+            runtime.EndInvokeJS(2, succeeded: true, ref reader);
+
+            Assert.True(task.IsCompletedSuccessfully);
         }
 
         [Fact]
@@ -113,18 +114,62 @@ namespace Microsoft.JSInterop.Tests
             var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
             Assert.False(unrelatedTask.IsCompleted);
             Assert.False(task.IsCompleted);
-            using var jsonDocument = JsonDocument.Parse("\"my result\"");
+            var bytes = Encoding.UTF8.GetBytes("\"my result\"");
+            var reader = new Utf8JsonReader(bytes);
 
             // Act/Assert: Task can be completed
-            runtime.OnEndInvoke(
+            runtime.EndInvokeJS(
                 runtime.BeginInvokeCalls[1].AsyncHandle,
                 /* succeeded: */ true,
-                new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement));
+                ref reader);
             Assert.False(unrelatedTask.IsCompleted);
             Assert.True(task.IsCompleted);
             Assert.Equal("my result", task.Result);
         }
 
+        [Fact]
+        public void CanCompleteAsyncCallsWithComplexType()
+        {
+            // Arrange
+            var runtime = new TestJSRuntime();
+
+            var task = runtime.InvokeAsync<TestPoco>("test identifier", Array.Empty<object>());
+            var bytes = Encoding.UTF8.GetBytes("{\"id\":10, \"name\": \"Test\"}");
+            var reader = new Utf8JsonReader(bytes);
+
+            // Act/Assert: Task can be completed
+            runtime.EndInvokeJS(
+                runtime.BeginInvokeCalls[0].AsyncHandle,
+                /* succeeded: */ true,
+                ref reader);
+            Assert.True(task.IsCompleted);
+            var poco = task.Result;
+            Assert.Equal(10, poco.Id);
+            Assert.Equal("Test", poco.Name);
+        }
+
+        [Fact]
+        public void CanCompleteAsyncCallsWithComplexTypeUsingPropertyCasing()
+        {
+            // Arrange
+            var runtime = new TestJSRuntime();
+
+            var task = runtime.InvokeAsync<TestPoco>("test identifier", Array.Empty<object>());
+            var bytes = Encoding.UTF8.GetBytes("{\"Id\":10, \"Name\": \"Test\"}");
+            var reader = new Utf8JsonReader(bytes);
+            reader.Read();
+
+            // Act/Assert: Task can be completed
+            runtime.EndInvokeJS(
+                runtime.BeginInvokeCalls[0].AsyncHandle,
+                /* succeeded: */ true,
+                ref reader);
+            Assert.True(task.IsCompleted);
+            var poco = task.Result;
+            Assert.Equal(10, poco.Id);
+            Assert.Equal("Test", poco.Name);
+        }
+
         [Fact]
         public void CanCompleteAsyncCallsAsFailure()
         {
@@ -136,13 +181,15 @@ namespace Microsoft.JSInterop.Tests
             var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
             Assert.False(unrelatedTask.IsCompleted);
             Assert.False(task.IsCompleted);
-            using var jsonDocument = JsonDocument.Parse("\"This is a test exception\"");
+            var bytes = Encoding.UTF8.GetBytes("\"This is a test exception\"");
+            var reader = new Utf8JsonReader(bytes);
+            reader.Read();
 
             // Act/Assert: Task can be failed
-            runtime.OnEndInvoke(
+            runtime.EndInvokeJS(
                 runtime.BeginInvokeCalls[1].AsyncHandle,
                 /* succeeded: */ false,
-                new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement));
+                ref reader);
             Assert.False(unrelatedTask.IsCompleted);
             Assert.True(task.IsCompleted);
 
@@ -152,7 +199,7 @@ namespace Microsoft.JSInterop.Tests
         }
 
         [Fact]
-        public async Task CanCompleteAsyncCallsWithErrorsDuringDeserialization()
+        public Task CanCompleteAsyncCallsWithErrorsDuringDeserialization()
         {
             // Arrange
             var runtime = new TestJSRuntime();
@@ -162,24 +209,27 @@ namespace Microsoft.JSInterop.Tests
             var task = runtime.InvokeAsync<int>("test identifier", Array.Empty<object>());
             Assert.False(unrelatedTask.IsCompleted);
             Assert.False(task.IsCompleted);
-            using var jsonDocument = JsonDocument.Parse("\"Not a string\"");
+            var bytes = Encoding.UTF8.GetBytes("Not a string");
+            var reader = new Utf8JsonReader(bytes);
 
             // Act/Assert: Task can be failed
-            runtime.OnEndInvoke(
+            runtime.EndInvokeJS(
                 runtime.BeginInvokeCalls[1].AsyncHandle,
                 /* succeeded: */ true,
-                new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement));
+                ref reader);
             Assert.False(unrelatedTask.IsCompleted);
 
-            var jsException = await Assert.ThrowsAsync<JSException>(() => task);
-            Assert.IsType<JsonException>(jsException.InnerException);
+            return AssertTask();
 
-            // Verify we've disposed the JsonDocument.
-            Assert.Throws<ObjectDisposedException>(() => jsonDocument.RootElement.ValueKind);
+            async Task AssertTask()
+            {
+                var jsException = await Assert.ThrowsAsync<JSException>(() => task);
+                Assert.IsAssignableFrom<JsonException>(jsException.InnerException);
+            }
         }
 
         [Fact]
-        public async Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync()
+        public Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync()
         {
             // Arrange
             var runtime = new TestJSRuntime();
@@ -187,11 +237,19 @@ namespace Microsoft.JSInterop.Tests
             // Act/Assert
             var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
             var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle;
-            runtime.OnEndInvoke(asyncHandle, true, new JSAsyncCallResult(JsonDocument.Parse("{}"), JsonDocument.Parse("{\"Message\": \"Some data\"}").RootElement.GetProperty("Message")));
-            runtime.OnEndInvoke(asyncHandle, false, new JSAsyncCallResult(null, JsonDocument.Parse("{\"Message\": \"Exception\"}").RootElement.GetProperty("Message")));
+            var firstReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Some data\""));
+            var secondReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Exception\""));
+
+            runtime.EndInvokeJS(asyncHandle, true, ref firstReader);
+            runtime.EndInvokeJS(asyncHandle, false, ref secondReader);
 
-            var result = await task;
-            Assert.Equal("Some data", result);
+            return AssertTask();
+
+            async Task AssertTask()
+            {
+                var result = await task;
+                Assert.Equal("Some data", result);
+            }
         }
 
         [Fact]
@@ -263,6 +321,13 @@ namespace Microsoft.JSInterop.Tests
             public string Message { get; set; }
         }
 
+        private class TestPoco
+        {
+            public int Id { get; set; }
+
+            public string Name { get; set; }
+        }
+
         class TestJSRuntime : JSRuntimeBase
         {
             public List<BeginInvokeAsyncArgs> BeginInvokeCalls = new List<BeginInvokeAsyncArgs>();
@@ -316,9 +381,6 @@ namespace Microsoft.JSInterop.Tests
                     ArgsJson = argsJson,
                 });
             }
-
-            public void OnEndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult callResult)
-                => EndInvokeJS(asyncHandle, succeeded, callResult);
         }
     }
 }