Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/Components/Analyzers/src/ComponentRefAndRenderModeAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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<InvocationExpressionSyntax>().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<InvocationExpressionSyntax>()
.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<InvocationExpressionSyntax> ComponentCalls)> AnalyzeComponentBlocks(
System.Collections.Generic.List<InvocationExpressionSyntax> invocations,
SemanticModel semanticModel)
{
var componentBlocks = new System.Collections.Generic.List<(InvocationExpressionSyntax, System.Collections.Generic.List<InvocationExpressionSyntax>)>();
InvocationExpressionSyntax? currentOpenComponent = null;
var currentComponentCalls = new System.Collections.Generic.List<InvocationExpressionSyntax>();

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<InvocationExpressionSyntax>();
}
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<InvocationExpressionSyntax>();
}
}
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;
}
}
9 changes: 9 additions & 0 deletions src/Components/Analyzers/src/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
9 changes: 9 additions & 0 deletions src/Components/Analyzers/src/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,13 @@
<data name="PersistentStateShouldNotHavePropertyInitializer_Title" xml:space="preserve">
<value>Property with [PersistentState] should not have initializer</value>
</data>
<data name="ComponentShouldNotUseRefAndRenderModeOnSameElement_Description" xml:space="preserve">
<value>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.</value>
</data>
<data name="ComponentShouldNotUseRefAndRenderModeOnSameElement_Format" xml:space="preserve">
<value>Component cannot use both @ref and @rendermode on the same element. The @ref will point to an SSRRenderModeBoundary instead of the component.</value>
</data>
<data name="ComponentShouldNotUseRefAndRenderModeOnSameElement_Title" xml:space="preserve">
<value>Component should not use @ref and @rendermode on the same element</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<TestComponent1>(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
{
}
}
Original file line number Diff line number Diff line change
@@ -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<TestComponent2>(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; }
}
}
Original file line number Diff line number Diff line change
@@ -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<TestComponent3>(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
{
}
}
Loading
Loading