Skip to content

Commit aaf9159

Browse files
authored
Add support for JSInvokable methods on generic types (dotnet/extensions#2342)
* Add support for JSInvokable methods on generic types Prior to this change, DotNetDispatcher cached the MethodInfo on the generic type definition. Using this would have required MethodInfo.MakeGenericMethod before the method was invoked. We could separately cache the result of this to avoid the reflection cost per invocation. Alternatively we could cache static and non-static MethodInfo instances separately which is what this change attempts to do. The big difference in the outcome is that this requires instance (non-static) JSInvokable methods to be only unique named within the type hierarchy as opposed to across all static and instance JSInvokable methods in an assembly. Fixes dotnet/extensions#1360 Fixes #9061 \n\nCommit migrated from dotnet/extensions@659b604
1 parent 64534e3 commit aaf9159

File tree

2 files changed

+142
-28
lines changed

2 files changed

+142
-28
lines changed

src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public static class DotNetDispatcher
2424
private static readonly ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
2525
= new ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
2626

27+
private static readonly ConcurrentDictionary<Type, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByType
28+
= new ConcurrentDictionary<Type, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
29+
2730
/// <summary>
2831
/// Receives a call from JS to .NET, locating and invoking the specified method.
2932
/// </summary>
@@ -129,9 +132,12 @@ private static object InvokeSynchronously(JSRuntime jsRuntime, in DotNetInvocati
129132
var methodIdentifier = callInfo.MethodIdentifier;
130133

131134
AssemblyKey assemblyKey;
135+
MethodInfo methodInfo;
136+
Type[] parameterTypes;
132137
if (objectReference is null)
133138
{
134139
assemblyKey = new AssemblyKey(assemblyName);
140+
(methodInfo, parameterTypes) = GetCachedMethodInfo(assemblyKey, methodIdentifier);
135141
}
136142
else
137143
{
@@ -147,11 +153,9 @@ private static object InvokeSynchronously(JSRuntime jsRuntime, in DotNetInvocati
147153
return default;
148154
}
149155

150-
assemblyKey = new AssemblyKey(objectReference.Value.GetType().Assembly);
156+
(methodInfo, parameterTypes) = GetCachedMethodInfo(objectReference, methodIdentifier);
151157
}
152158

153-
var (methodInfo, parameterTypes) = GetCachedMethodInfo(assemblyKey, methodIdentifier);
154-
155159
var suppliedArgs = ParseArguments(jsRuntime, methodIdentifier, argsJson, parameterTypes);
156160

157161
try
@@ -301,7 +305,47 @@ private static (MethodInfo, Type[]) GetCachedMethodInfo(AssemblyKey assemblyKey,
301305
}
302306
else
303307
{
304-
throw new ArgumentException($"The assembly '{assemblyKey.AssemblyName}' does not contain a public method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
308+
throw new ArgumentException($"The assembly '{assemblyKey.AssemblyName}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
309+
}
310+
}
311+
312+
private static (MethodInfo methodInfo, Type[] parameterTypes) GetCachedMethodInfo(IDotNetObjectReference objectReference, string methodIdentifier)
313+
{
314+
var type = objectReference.Value.GetType();
315+
var assemblyMethods = _cachedMethodsByType.GetOrAdd(type, ScanTypeForCallableMethods);
316+
if (assemblyMethods.TryGetValue(methodIdentifier, out var result))
317+
{
318+
return result;
319+
}
320+
else
321+
{
322+
throw new ArgumentException($"The type '{type.Name}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
323+
}
324+
325+
static Dictionary<string, (MethodInfo, Type[])> ScanTypeForCallableMethods(Type type)
326+
{
327+
var result = new Dictionary<string, (MethodInfo, Type[])>(StringComparer.Ordinal);
328+
var invokableMethods = type
329+
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
330+
.Where(method => !method.ContainsGenericParameters && method.IsDefined(typeof(JSInvokableAttribute), inherit: false));
331+
332+
foreach (var method in invokableMethods)
333+
{
334+
var identifier = method.GetCustomAttribute<JSInvokableAttribute>(false).Identifier ?? method.Name;
335+
var parameterTypes = method.GetParameters().Select(p => p.ParameterType).ToArray();
336+
337+
if (result.ContainsKey(identifier))
338+
{
339+
throw new InvalidOperationException($"The type {type.Name} contains more than one " +
340+
$"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " +
341+
$"type must have different identifiers. You can pass a custom identifier as a parameter to " +
342+
$"the [JSInvokable] attribute.");
343+
}
344+
345+
result.Add(identifier, (method, parameterTypes));
346+
}
347+
348+
return result;
305349
}
306350
}
307351

@@ -312,35 +356,22 @@ private static (MethodInfo, Type[]) GetCachedMethodInfo(AssemblyKey assemblyKey,
312356
var result = new Dictionary<string, (MethodInfo, Type[])>(StringComparer.Ordinal);
313357
var invokableMethods = GetRequiredLoadedAssembly(assemblyKey)
314358
.GetExportedTypes()
315-
.SelectMany(type => type.GetMethods(
316-
BindingFlags.Public |
317-
BindingFlags.DeclaredOnly |
318-
BindingFlags.Instance |
319-
BindingFlags.Static))
320-
.Where(method => method.IsDefined(typeof(JSInvokableAttribute), inherit: false));
359+
.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static))
360+
.Where(method => !method.ContainsGenericParameters && method.IsDefined(typeof(JSInvokableAttribute), inherit: false));
321361
foreach (var method in invokableMethods)
322362
{
323363
var identifier = method.GetCustomAttribute<JSInvokableAttribute>(false).Identifier ?? method.Name;
324364
var parameterTypes = method.GetParameters().Select(p => p.ParameterType).ToArray();
325365

326-
try
366+
if (result.ContainsKey(identifier))
327367
{
328-
result.Add(identifier, (method, parameterTypes));
329-
}
330-
catch (ArgumentException)
331-
{
332-
if (result.ContainsKey(identifier))
333-
{
334-
throw new InvalidOperationException($"The assembly '{assemblyKey.AssemblyName}' contains more than one " +
335-
$"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " +
336-
$"assembly must have different identifiers. You can pass a custom identifier as a parameter to " +
337-
$"the [JSInvokable] attribute.");
338-
}
339-
else
340-
{
341-
throw;
342-
}
368+
throw new InvalidOperationException($"The assembly '{assemblyKey.AssemblyName}' contains more than one " +
369+
$"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " +
370+
$"assembly must have different identifiers. You can pass a custom identifier as a parameter to " +
371+
$"the [JSInvokable] attribute.");
343372
}
373+
374+
result.Add(identifier, (method, parameterTypes));
344375
}
345376

346377
return result;

src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherTest.cs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System;
55
using System.Linq;
6-
using System.Runtime.ExceptionServices;
76
using System.Text.Json;
87
using System.Threading;
98
using System.Threading.Tasks;
@@ -70,7 +69,7 @@ public void CannotInvokeUnsuitableMethods(string methodIdentifier)
7069
DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo(thisAssemblyName, methodIdentifier, default, default), null);
7170
});
7271

73-
Assert.Equal($"The assembly '{thisAssemblyName}' does not contain a public method with [JSInvokableAttribute(\"{methodIdentifier}\")].", ex.Message);
72+
Assert.Equal($"The assembly '{thisAssemblyName}' does not contain a public invokable method with [JSInvokableAttribute(\"{methodIdentifier}\")].", ex.Message);
7473
}
7574

7675
[Fact]
@@ -355,6 +354,78 @@ public void CanInvokeInstanceMethodWithParams()
355354
Assert.Equal("MY STRING", resultDto.StringVal);
356355
}
357356

357+
[Fact]
358+
public void CanInvokeNonGenericInstanceMethodOnGenericType()
359+
{
360+
var jsRuntime = new TestJSRuntime();
361+
var targetInstance = new GenericType<int>();
362+
jsRuntime.Invoke<object>("_setup",
363+
DotNetObjectReference.Create(targetInstance));
364+
var argsJson = "[\"hello world\"]";
365+
366+
// Act
367+
var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType<int>.EchoStringParameter), 1, default), argsJson);
368+
369+
// Assert
370+
Assert.Equal("\"hello world\"", resultJson);
371+
}
372+
373+
[Fact]
374+
public void CanInvokeMethodsThatAcceptGenericParametersOnGenericTypes()
375+
{
376+
var jsRuntime = new TestJSRuntime();
377+
var targetInstance = new GenericType<string>();
378+
jsRuntime.Invoke<object>("_setup",
379+
DotNetObjectReference.Create(targetInstance));
380+
var argsJson = "[\"hello world\"]";
381+
382+
// Act
383+
var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType<string>.EchoParameter), 1, default), argsJson);
384+
385+
// Assert
386+
Assert.Equal("\"hello world\"", resultJson);
387+
}
388+
389+
[Fact]
390+
public void CannotInvokeStaticOpenGenericMethods()
391+
{
392+
var methodIdentifier = "StaticGenericMethod";
393+
var jsRuntime = new TestJSRuntime();
394+
395+
// Act
396+
var ex = Assert.Throws<ArgumentException>(() => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, methodIdentifier, 0, default), "[7]"));
397+
Assert.Contains($"The assembly '{thisAssemblyName}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].", ex.Message);
398+
}
399+
400+
[Fact]
401+
public void CannotInvokeInstanceOpenGenericMethods()
402+
{
403+
var methodIdentifier = "InstanceGenericMethod";
404+
var targetInstance = new GenericType<int>();
405+
var jsRuntime = new TestJSRuntime();
406+
jsRuntime.Invoke<object>("_setup",
407+
DotNetObjectReference.Create(targetInstance));
408+
var argsJson = "[\"hello world\"]";
409+
410+
// Act
411+
var ex = Assert.Throws<ArgumentException>(() => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, methodIdentifier, 1, default), argsJson));
412+
Assert.Contains($"The type 'GenericType`1' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].", ex.Message);
413+
}
414+
415+
[Fact]
416+
public void CannotInvokeMethodsWithGenericParameters_IfTypesDoNotMatch()
417+
{
418+
var jsRuntime = new TestJSRuntime();
419+
var targetInstance = new GenericType<int>();
420+
jsRuntime.Invoke<object>("_setup",
421+
DotNetObjectReference.Create(targetInstance));
422+
var argsJson = "[\"hello world\"]";
423+
424+
// Act & Assert
425+
Assert.Throws<JsonException>(() =>
426+
DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType<int>.EchoParameter), 1, default), argsJson));
427+
}
428+
358429
[Fact]
359430
public void CannotInvokeWithFewerNumberOfParameters()
360431
{
@@ -790,6 +861,18 @@ public static async Task<string> AsyncThrowingMethod()
790861
}
791862
}
792863

864+
public class GenericType<TValue>
865+
{
866+
[JSInvokable] public string EchoStringParameter(string input) => input;
867+
[JSInvokable] public TValue EchoParameter(TValue input) => input;
868+
}
869+
870+
public class GenericMethodClass
871+
{
872+
[JSInvokable("StaticGenericMethod")] public static string StaticGenericMethod<TValue>(TValue input) => input.ToString();
873+
[JSInvokable("InstanceGenericMethod")] public string GenericMethod<TValue>(TValue input) => input.ToString();
874+
}
875+
793876
public class TestJSRuntime : JSInProcessRuntime
794877
{
795878
private TaskCompletionSource<object> _nextInvocationTcs = new TaskCompletionSource<object>();

0 commit comments

Comments
 (0)