diff --git a/src/Components/Analyzers/src/DiagnosticDescriptors.cs b/src/Components/Analyzers/src/DiagnosticDescriptors.cs index 5f67edaf8447..5259cbd0ba6c 100644 --- a/src/Components/Analyzers/src/DiagnosticDescriptors.cs +++ b/src/Components/Analyzers/src/DiagnosticDescriptors.cs @@ -92,4 +92,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true, description: CreateLocalizableResourceString(nameof(Resources.PersistentStateShouldNotHavePropertyInitializer_Description))); + + public static readonly DiagnosticDescriptor UseInvokeVoidAsyncForObjectReturn = new( + "BL0010", + CreateLocalizableResourceString(nameof(Resources.UseInvokeVoidAsyncForObjectReturn_Title)), + CreateLocalizableResourceString(nameof(Resources.UseInvokeVoidAsyncForObjectReturn_Format)), + Usage, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: CreateLocalizableResourceString(nameof(Resources.UseInvokeVoidAsyncForObjectReturn_Description))); } diff --git a/src/Components/Analyzers/src/InvokeAsyncOfObjectAnalyzer.cs b/src/Components/Analyzers/src/InvokeAsyncOfObjectAnalyzer.cs new file mode 100644 index 000000000000..fa668db8357a --- /dev/null +++ b/src/Components/Analyzers/src/InvokeAsyncOfObjectAnalyzer.cs @@ -0,0 +1,166 @@ +// 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.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +#nullable enable + +namespace Microsoft.AspNetCore.Components.Analyzers; + +/// +/// Analyzer that detects usage of InvokeAsync<object> and recommends using InvokeVoidAsync instead. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class InvokeAsyncOfObjectAnalyzer : DiagnosticAnalyzer +{ + private const string JSRuntimeExtensionsTypeName = "Microsoft.JSInterop.JSRuntimeExtensions"; + private const string JSObjectReferenceExtensionsTypeName = "Microsoft.JSInterop.JSObjectReferenceExtensions"; + private const string JSInProcessRuntimeExtensionsTypeName = "Microsoft.JSInterop.JSInProcessRuntimeExtensions"; + private const string JSInProcessObjectReferenceExtensionsTypeName = "Microsoft.JSInterop.JSInProcessObjectReferenceExtensions"; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterCompilationStartAction(compilationContext => + { + // Cache type lookups once per compilation + var ijsRuntimeType = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.JSInterop.IJSRuntime"); + var ijsObjectReferenceType = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.JSInterop.IJSObjectReference"); + var ijsInProcessRuntimeType = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.JSInterop.IJSInProcessRuntime"); + var ijsInProcessObjectReferenceType = compilationContext.Compilation.GetTypeByMetadataName("Microsoft.JSInterop.IJSInProcessObjectReference"); + var jsRuntimeExtensionsType = compilationContext.Compilation.GetTypeByMetadataName(JSRuntimeExtensionsTypeName); + var jsObjectReferenceExtensionsType = compilationContext.Compilation.GetTypeByMetadataName(JSObjectReferenceExtensionsTypeName); + var jsInProcessRuntimeExtensionsType = compilationContext.Compilation.GetTypeByMetadataName(JSInProcessRuntimeExtensionsTypeName); + var jsInProcessObjectReferenceExtensionsType = compilationContext.Compilation.GetTypeByMetadataName(JSInProcessObjectReferenceExtensionsTypeName); + var objectType = compilationContext.Compilation.GetSpecialType(SpecialType.System_Object); + + if (ijsRuntimeType is null && ijsObjectReferenceType is null) + { + // JSInterop types are not available + return; + } + + compilationContext.RegisterOperationAction(operationContext => + { + var invocation = (IInvocationOperation)operationContext.Operation; + var targetMethod = invocation.TargetMethod; + + // Check if the method is named InvokeAsync and is generic + if (targetMethod.Name != "InvokeAsync" || !targetMethod.IsGenericMethod) + { + return; + } + + // Check if the type argument is object + if (targetMethod.TypeArguments.Length != 1 || + !SymbolEqualityComparer.Default.Equals(targetMethod.TypeArguments[0], objectType)) + { + return; + } + + // Check if the method is on IJSRuntime, IJSObjectReference, or their in-process variants + // This includes extension methods on these types + var containingType = targetMethod.ContainingType; + var receiverType = GetReceiverType(invocation); + + if (!IsJSInteropType(receiverType, ijsRuntimeType, ijsObjectReferenceType, ijsInProcessRuntimeType, ijsInProcessObjectReferenceType) && + !IsJSInteropExtensionClass(containingType, jsRuntimeExtensionsType, jsObjectReferenceExtensionsType, jsInProcessRuntimeExtensionsType, jsInProcessObjectReferenceExtensionsType)) + { + return; + } + + operationContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn, + invocation.Syntax.GetLocation())); + }, OperationKind.Invocation); + }); + } + + private static ITypeSymbol? GetReceiverType(IInvocationOperation invocation) + { + // For extension methods, the first argument is the receiver + if (invocation.TargetMethod.IsExtensionMethod && invocation.Arguments.Length > 0) + { + return invocation.Arguments[0].Value.Type; + } + + // For instance methods + return invocation.Instance?.Type; + } + + private static bool IsJSInteropType( + ITypeSymbol? type, + INamedTypeSymbol? ijsRuntimeType, + INamedTypeSymbol? ijsObjectReferenceType, + INamedTypeSymbol? ijsInProcessRuntimeType, + INamedTypeSymbol? ijsInProcessObjectReferenceType) + { + if (type is null) + { + return false; + } + + // Check if the type implements any of the JSInterop interfaces + if (ijsRuntimeType is not null && ImplementsInterface(type, ijsRuntimeType)) + { + return true; + } + + if (ijsObjectReferenceType is not null && ImplementsInterface(type, ijsObjectReferenceType)) + { + return true; + } + + if (ijsInProcessRuntimeType is not null && ImplementsInterface(type, ijsInProcessRuntimeType)) + { + return true; + } + + if (ijsInProcessObjectReferenceType is not null && ImplementsInterface(type, ijsInProcessObjectReferenceType)) + { + return true; + } + + return false; + } + + private static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol interfaceType) + { + if (SymbolEqualityComparer.Default.Equals(type, interfaceType)) + { + return true; + } + + foreach (var iface in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface, interfaceType)) + { + return true; + } + } + + return false; + } + + private static bool IsJSInteropExtensionClass( + INamedTypeSymbol containingType, + INamedTypeSymbol? jsRuntimeExtensionsType, + INamedTypeSymbol? jsObjectReferenceExtensionsType, + INamedTypeSymbol? jsInProcessRuntimeExtensionsType, + INamedTypeSymbol? jsInProcessObjectReferenceExtensionsType) + { + // Use symbol equality comparison instead of string comparison + return SymbolEqualityComparer.Default.Equals(containingType, jsRuntimeExtensionsType) || + SymbolEqualityComparer.Default.Equals(containingType, jsObjectReferenceExtensionsType) || + SymbolEqualityComparer.Default.Equals(containingType, jsInProcessRuntimeExtensionsType) || + SymbolEqualityComparer.Default.Equals(containingType, jsInProcessObjectReferenceExtensionsType); + } +} diff --git a/src/Components/Analyzers/src/Resources.resx b/src/Components/Analyzers/src/Resources.resx index 6a23211094aa..5c193fd5c376 100644 --- a/src/Components/Analyzers/src/Resources.resx +++ b/src/Components/Analyzers/src/Resources.resx @@ -198,4 +198,13 @@ Property with [PersistentState] should not have initializer + + JavaScript interop calls that do not expect a return value should use InvokeVoidAsync instead of InvokeAsync<object>. Using InvokeAsync<object> may cause serialization issues with non-serializable JavaScript values. + + + Use 'InvokeVoidAsync' instead of 'InvokeAsync<object>'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value. + + + Use InvokeVoidAsync instead of InvokeAsync<object> + \ No newline at end of file diff --git a/src/Components/Analyzers/test/InvokeAsyncOfObjectAnalyzerTest.cs b/src/Components/Analyzers/test/InvokeAsyncOfObjectAnalyzerTest.cs new file mode 100644 index 000000000000..a8fa25afa05b --- /dev/null +++ b/src/Components/Analyzers/test/InvokeAsyncOfObjectAnalyzerTest.cs @@ -0,0 +1,507 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using TestHelper; + +namespace Microsoft.AspNetCore.Components.Analyzers.Test; + +public class InvokeAsyncOfObjectAnalyzerTest : DiagnosticVerifier +{ + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() => new InvokeAsyncOfObjectAnalyzer(); + + private static readonly string JSInteropDeclarations = @" + namespace Microsoft.JSInterop + { + using System.Threading; + using System.Threading.Tasks; + + public interface IJSRuntime + { + ValueTask InvokeAsync(string identifier, object[] args); + ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args); + } + + public interface IJSObjectReference : System.IAsyncDisposable + { + ValueTask InvokeAsync(string identifier, object[] args); + ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args); + } + + public interface IJSInProcessRuntime : IJSRuntime + { + TValue Invoke(string identifier, params object[] args); + } + + public interface IJSInProcessObjectReference : IJSObjectReference + { + TValue Invoke(string identifier, params object[] args); + } + + public static class JSRuntimeExtensions + { + public static ValueTask InvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, params object[] args) + => default; + public static ValueTask InvokeAsync(this IJSRuntime jsRuntime, string identifier, params object[] args) + => default; + public static ValueTask InvokeAsync(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object[] args) + => default; + public static ValueTask InvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object[] args) + => default; + } + + public static class JSObjectReferenceExtensions + { + public static ValueTask InvokeVoidAsync(this IJSObjectReference jsObjectReference, string identifier, params object[] args) + => default; + public static ValueTask InvokeAsync(this IJSObjectReference jsObjectReference, string identifier, params object[] args) + => default; + public static ValueTask InvokeAsync(this IJSObjectReference jsObjectReference, string identifier, CancellationToken cancellationToken, params object[] args) + => default; + public static ValueTask InvokeVoidAsync(this IJSObjectReference jsObjectReference, string identifier, CancellationToken cancellationToken, params object[] args) + => default; + } + } +"; + + [Fact] + public void NoDiagnosticForInvokeVoidAsync() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + await _jsRuntime.InvokeVoidAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void NoDiagnosticForInvokeAsyncWithTypedReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + return await _jsRuntime.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void DiagnosticForInvokeAsyncWithObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + await _jsRuntime.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void DiagnosticForExtensionMethodInvokeAsyncWithObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + await JSRuntimeExtensions.InvokeAsync(_jsRuntime, ""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void DiagnosticForIJSObjectReferenceInvokeAsyncWithObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSObjectReference _jsObjectReference; + + public async Task TestMethod() + { + await _jsObjectReference.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void DiagnosticForInvokeAsyncWithCancellationTokenAndObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod(CancellationToken ct) + { + await _jsRuntime.InvokeAsync(""myFunction"", ct, null); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 14, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void NoDiagnosticForNonJSInteropInvokeAsync() + { + var test = @" + namespace ConsoleApplication1 + { + using System.Threading.Tasks; + + class TestClass + { + public ValueTask InvokeAsync(string identifier, object[] args) + => default; + + public async Task TestMethod() + { + await InvokeAsync(""myFunction"", null); + } + } + }"; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void NoDiagnosticForInvokeAsyncWithIntReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + return await _jsRuntime.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void NoDiagnosticForInvokeAsyncWithCustomClassReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class MyResult { } + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + return await _jsRuntime.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void DiagnosticForIJSInProcessRuntimeInvokeAsyncWithObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSInProcessRuntime _jsRuntime; + + public async Task TestMethod() + { + await _jsRuntime.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void DiagnosticForIJSInProcessObjectReferenceInvokeAsyncWithObjectReturn() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSInProcessObjectReference _jsObjectRef; + + public async Task TestMethod() + { + await _jsObjectRef.InvokeAsync(""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void MultipleInvocationsReportMultipleDiagnostics() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + await _jsRuntime.InvokeAsync(""myFunction1""); + await _jsRuntime.InvokeAsync(""myFunction2""); + } + } + }" + JSInteropDeclarations; + + var expected1 = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + var expected2 = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 14, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected1, expected2); + } + + [Fact] + public void DiagnosticForJSObjectReferenceExtensionMethod() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSObjectReference _jsObjectRef; + + public async Task TestMethod() + { + await JSObjectReferenceExtensions.InvokeAsync(_jsObjectRef, ""myFunction""); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 23) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } + + [Fact] + public void DiagnosticForInvokeAsyncWithObjectReturnAssignedToVariable() + { + // This test confirms that the diagnostic still fires when the result is assigned to a variable. + // Using InvokeAsync is problematic because 'object' cannot be properly deserialized from JSON - + // the result will either be null or cause serialization errors if JavaScript returns a non-serializable value. + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.JSInterop; + using System.Threading.Tasks; + + class TestClass + { + private IJSRuntime _jsRuntime; + + public async Task TestMethod() + { + var result = await _jsRuntime.InvokeAsync(""myFunction""); + System.Console.WriteLine(result); + } + } + }" + JSInteropDeclarations; + + var expected = new DiagnosticResult + { + Id = DiagnosticDescriptors.UseInvokeVoidAsyncForObjectReturn.Id, + Message = "Use 'InvokeVoidAsync' instead of 'InvokeAsync'. Return values of type 'object' cannot be deserialized and may cause serialization errors if the JavaScript function returns a non-serializable value.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 13, 36) + } + }; + + VerifyCSharpDiagnostic(test, expected); + } +}