diff --git a/src/Components/Analyzers/src/ComponentRefAndRenderModeAnalyzer.cs b/src/Components/Analyzers/src/ComponentRefAndRenderModeAnalyzer.cs new file mode 100644 index 000000000000..7850e81043ee --- /dev/null +++ b/src/Components/Analyzers/src/ComponentRefAndRenderModeAnalyzer.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 System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Components.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ComponentRefAndRenderModeAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + DiagnosticDescriptors.ComponentShouldNotUseRefAndRenderModeOnSameElement); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); + } + + private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) + { + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + + // Only analyze methods that appear to be Blazor component BuildRenderTree methods + if (!IsComponentBuildRenderTreeMethod(methodDeclaration)) + { + return; + } + + // Find all invocation expressions in the method + var invocations = methodDeclaration.DescendantNodes().OfType().ToList(); + + // Group invocations by the component they're operating on by looking at OpenComponent/CloseComponent pairs + var componentBlocks = AnalyzeComponentBlocks(invocations, context.SemanticModel); + + foreach (var (openComponentCall, componentCalls) in componentBlocks) + { + var hasReferenceCapture = componentCalls.Any(call => IsAddComponentReferenceCapture(call, context.SemanticModel)); + var hasRenderMode = componentCalls.Any(call => IsAddComponentRenderMode(call, context.SemanticModel)); + + if (hasReferenceCapture && hasRenderMode) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.ComponentShouldNotUseRefAndRenderModeOnSameElement, + openComponentCall.GetLocation()); + + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsComponentBuildRenderTreeMethod(MethodDeclarationSyntax method) + { + // Check if this looks like a BuildRenderTree method or similar component method + // These methods typically contain calls to RenderTreeBuilder methods + return method.DescendantNodes() + .OfType() + .Any(invocation => + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var memberName = memberAccess.Name.Identifier.ValueText; + return memberName is "OpenComponent" or "AddComponentParameter" or "AddComponentRenderMode" or "AddComponentReferenceCapture"; + } + return false; + }); + } + + private static System.Collections.Generic.List<(InvocationExpressionSyntax OpenComponent, System.Collections.Generic.List ComponentCalls)> AnalyzeComponentBlocks( + System.Collections.Generic.List invocations, + SemanticModel semanticModel) + { + var componentBlocks = new System.Collections.Generic.List<(InvocationExpressionSyntax, System.Collections.Generic.List)>(); + InvocationExpressionSyntax? currentOpenComponent = null; + var currentComponentCalls = new System.Collections.Generic.List(); + + foreach (var invocation in invocations) + { + if (IsOpenComponent(invocation, semanticModel)) + { + // If we have a previous component block, save it + if (currentOpenComponent is not null) + { + componentBlocks.Add((currentOpenComponent, currentComponentCalls)); + } + + // Start a new component block + currentOpenComponent = invocation; + currentComponentCalls = new System.Collections.Generic.List(); + } + else if (IsCloseComponent(invocation, semanticModel)) + { + // End the current component block + if (currentOpenComponent is not null) + { + componentBlocks.Add((currentOpenComponent, currentComponentCalls)); + currentOpenComponent = null; + currentComponentCalls = new System.Collections.Generic.List(); + } + } + else if (currentOpenComponent is not null && IsComponentRelatedCall(invocation, semanticModel)) + { + // Add to the current component block + currentComponentCalls.Add(invocation); + } + } + + // Handle case where method ends without CloseComponent + if (currentOpenComponent is not null) + { + componentBlocks.Add((currentOpenComponent, currentComponentCalls)); + } + + return componentBlocks; + } + + private static bool IsOpenComponent(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + return IsMethodCall(invocation, semanticModel, "OpenComponent"); + } + + private static bool IsCloseComponent(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + return IsMethodCall(invocation, semanticModel, "CloseComponent"); + } + + private static bool IsAddComponentReferenceCapture(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + return IsMethodCall(invocation, semanticModel, "AddComponentReferenceCapture"); + } + + private static bool IsAddComponentRenderMode(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + return IsMethodCall(invocation, semanticModel, "AddComponentRenderMode"); + } + + private static bool IsComponentRelatedCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + return IsMethodCall(invocation, semanticModel, "AddComponentParameter") || + IsMethodCall(invocation, semanticModel, "AddComponentReferenceCapture") || + IsMethodCall(invocation, semanticModel, "AddComponentRenderMode"); + } + + private static bool IsMethodCall(InvocationExpressionSyntax invocation, SemanticModel semanticModel, string methodName) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.ValueText == methodName) + { + // Additional validation: check if this is actually called on RenderTreeBuilder + var symbolInfo = semanticModel.GetSymbolInfo(memberAccess); + if (symbolInfo.Symbol is IMethodSymbol method) + { + return method.ContainingType?.Name == "RenderTreeBuilder" && + method.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore.Components.Rendering"; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/src/DiagnosticDescriptors.cs b/src/Components/Analyzers/src/DiagnosticDescriptors.cs index 5f67edaf8447..7e90b3ee007a 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 ComponentShouldNotUseRefAndRenderModeOnSameElement = new( + "BL0010", + CreateLocalizableResourceString(nameof(Resources.ComponentShouldNotUseRefAndRenderModeOnSameElement_Title)), + CreateLocalizableResourceString(nameof(Resources.ComponentShouldNotUseRefAndRenderModeOnSameElement_Format)), + Usage, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: CreateLocalizableResourceString(nameof(Resources.ComponentShouldNotUseRefAndRenderModeOnSameElement_Description))); } diff --git a/src/Components/Analyzers/src/Resources.resx b/src/Components/Analyzers/src/Resources.resx index 6a23211094aa..d7d86acac9dc 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 + + Using both @ref and @rendermode on the same component will cause the reference to point to an SSRRenderModeBoundary instead of the component itself. Remove @rendermode or use a separate component without @rendermode for the reference. + + + Component cannot use both @ref and @rendermode on the same element. The @ref will point to an SSRRenderModeBoundary instead of the component. + + + Component should not use @ref and @rendermode on the same element + \ No newline at end of file diff --git a/src/Components/Analyzers/test/ComponentRefAndRenderModeAnalyzerTest.cs b/src/Components/Analyzers/test/ComponentRefAndRenderModeAnalyzerTest.cs new file mode 100644 index 000000000000..772d13e12df1 --- /dev/null +++ b/src/Components/Analyzers/test/ComponentRefAndRenderModeAnalyzerTest.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Analyzer.Testing; + +namespace Microsoft.AspNetCore.Components.Analyzers; + +public class ComponentRefAndRenderModeAnalyzerTest : AnalyzerTestBase +{ + public ComponentRefAndRenderModeAnalyzerTest() + { + Analyzer = new ComponentRefAndRenderModeAnalyzer(); + Runner = new ComponentAnalyzerDiagnosticAnalyzerRunner(Analyzer); + } + + private ComponentRefAndRenderModeAnalyzer Analyzer { get; } + private ComponentAnalyzerDiagnosticAnalyzerRunner Runner { get; } + + [Fact] + public async Task ComponentWithBothRefAndRenderMode_ReportsDiagnostic() + { + // Arrange + var source = Read("ComponentWithBothRefAndRenderMode"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Collection( + diagnostics, + diagnostic => + { + Assert.Same(DiagnosticDescriptors.ComponentShouldNotUseRefAndRenderModeOnSameElement, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(source.MarkerLocations["MM1"], diagnostic.Location); + }); + } + + [Fact] + public async Task ComponentWithOnlyRef_DoesNotReportDiagnostic() + { + // Arrange + var source = Read("ComponentWithOnlyRef"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ComponentWithOnlyRenderMode_DoesNotReportDiagnostic() + { + // Arrange + var source = Read("ComponentWithOnlyRenderMode"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public async Task MultipleComponentsWithMixedUsage_ReportsCorrectDiagnostics() + { + // Arrange + var source = Read("MultipleComponentsWithMixedUsage"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Collection( + diagnostics, + diagnostic => + { + Assert.Same(DiagnosticDescriptors.ComponentShouldNotUseRefAndRenderModeOnSameElement, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(source.MarkerLocations["MM1"], diagnostic.Location); + }); + } + + [Fact] + public async Task NonComponentMethod_DoesNotReportDiagnostic() + { + // Arrange + var source = Read("NonComponentMethod"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + Assert.Empty(diagnostics); + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithBothRefAndRenderMode.cs b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithBothRefAndRenderMode.cs new file mode 100644 index 000000000000..fafa6fe7b58a --- /dev/null +++ b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithBothRefAndRenderMode.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Analyzers.Tests.TestFiles.ComponentRefAndRenderModeAnalyzerTest +{ + public class ComponentWithBothRefAndRenderMode : ComponentBase + { + private TestComponent1 componentRef; + private readonly InteractiveServerRenderMode1 renderMode = new InteractiveServerRenderMode1(); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + /*MM1*/builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestComponent1.Value), 42); + builder.AddComponentReferenceCapture(2, component => componentRef = (TestComponent1)component); + builder.AddComponentRenderMode(renderMode); + builder.CloseComponent(); + } + } + + public class TestComponent1 : ComponentBase + { + [Parameter] public int Value { get; set; } + } + + public class InteractiveServerRenderMode1 : IComponentRenderMode + { + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRef.cs b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRef.cs new file mode 100644 index 000000000000..a43b8af62261 --- /dev/null +++ b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRef.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Analyzers.Tests.TestFiles.ComponentRefAndRenderModeAnalyzerTest +{ + public class ComponentWithOnlyRef : ComponentBase + { + private TestComponent2 componentRef; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestComponent2.Value), 42); + builder.AddComponentReferenceCapture(2, component => componentRef = (TestComponent2)component); + builder.CloseComponent(); + } + } + + public class TestComponent2 : ComponentBase + { + [Parameter] public int Value { get; set; } + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRenderMode.cs b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRenderMode.cs new file mode 100644 index 000000000000..1303b7a0c38b --- /dev/null +++ b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/ComponentWithOnlyRenderMode.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Analyzers.Tests.TestFiles.ComponentRefAndRenderModeAnalyzerTest +{ + public class ComponentWithOnlyRenderMode : ComponentBase + { + private readonly InteractiveServerRenderMode3 renderMode = new InteractiveServerRenderMode3(); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestComponent3.Value), 42); + builder.AddComponentRenderMode(renderMode); + builder.CloseComponent(); + } + } + + public class TestComponent3 : ComponentBase + { + [Parameter] public int Value { get; set; } + } + + public class InteractiveServerRenderMode3 : IComponentRenderMode + { + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/MultipleComponentsWithMixedUsage.cs b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/MultipleComponentsWithMixedUsage.cs new file mode 100644 index 000000000000..d80969785807 --- /dev/null +++ b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/MultipleComponentsWithMixedUsage.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Analyzers.Tests.TestFiles.ComponentRefAndRenderModeAnalyzerTest +{ + public class MultipleComponentsWithMixedUsage : ComponentBase + { + private TestComponent4 componentRef1; + private TestComponent4 componentRef2; + private readonly InteractiveServerRenderMode4 renderMode = new InteractiveServerRenderMode4(); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + // Component with only ref - should not report diagnostic + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(TestComponent4.Value), 42); + builder.AddComponentReferenceCapture(2, component => componentRef1 = (TestComponent4)component); + builder.CloseComponent(); + + // Component with both ref and rendermode - should report diagnostic + /*MM1*/builder.OpenComponent(3); + builder.AddComponentParameter(4, nameof(TestComponent4.Value), 100); + builder.AddComponentReferenceCapture(5, component => componentRef2 = (TestComponent4)component); + builder.AddComponentRenderMode(renderMode); + builder.CloseComponent(); + + // Component with only rendermode - should not report diagnostic + builder.OpenComponent(6); + builder.AddComponentParameter(7, nameof(TestComponent4.Value), 200); + builder.AddComponentRenderMode(renderMode); + builder.CloseComponent(); + } + } + + public class TestComponent4 : ComponentBase + { + [Parameter] public int Value { get; set; } + } + + public class InteractiveServerRenderMode4 : IComponentRenderMode + { + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/NonComponentMethod.cs b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/NonComponentMethod.cs new file mode 100644 index 000000000000..8f7e20d0d6e5 --- /dev/null +++ b/src/Components/Analyzers/test/TestFiles/ComponentRefAndRenderModeAnalyzerTest/NonComponentMethod.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Analyzers.Tests.TestFiles.ComponentRefAndRenderModeAnalyzerTest +{ + public class NonComponentMethod + { + public void SomeMethod() + { + // This method doesn't use RenderTreeBuilder methods, so should not be analyzed + var x = 1; + var y = 2; + var z = x + y; + } + + public void AnotherMethod(RenderTreeBuilder builder) + { + // This method uses RenderTreeBuilder but is not a component BuildRenderTree method + // It should not be analyzed since it's not in a component context + builder.OpenElement(0, "div"); + builder.AddContent(1, "Hello World"); + builder.CloseElement(); + } + } +} \ No newline at end of file