Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d6a7eef
Add VirtualizeAnchorMode with anchor snapshot/restore for variable-he…
ilonatommy Apr 9, 2026
8580655
Fix tests.
ilonatommy Apr 11, 2026
21b4f19
Unit tests were missing anchorMode.
ilonatommy Apr 13, 2026
f8cd56e
Small cleanup.
ilonatommy Apr 13, 2026
7dda12c
Fix - better tests that match real UX, bigger varaibility range and s…
ilonatommy Apr 14, 2026
2f1c3ff
New test: make sure that before scroll happens the scrol is positione…
ilonatommy Apr 15, 2026
a45ddf3
Indeces.
ilonatommy Apr 15, 2026
32d174f
Fix provider case with `ItemKey` approach.
ilonatommy Apr 17, 2026
3bcdb85
ItemProvider support + more tests for window scroll, same as we have …
ilonatommy Apr 23, 2026
ad52722
Refactoring.
ilonatommy Apr 23, 2026
d14dad3
Make test deterministic
ilonatommy Apr 24, 2026
2f97b7b
Feedback.
ilonatommy Apr 24, 2026
9677323
Warning is too silent, throw instead.
ilonatommy Apr 24, 2026
49929f7
Analyzer instead of exception.
ilonatommy Apr 24, 2026
dd4e6fa
Update analyzer text.
ilonatommy Apr 24, 2026
57d5c88
Fix in-DOM auto-follow + do not re-engage after user scrolls away fro…
ilonatommy Apr 25, 2026
e47092b
Test item provider loading with delay. Fix tests to not modify the co…
ilonatommy Apr 27, 2026
b23d5a7
Test for shost lists without scroll that grow to have a scroll - end …
ilonatommy Apr 27, 2026
f06c521
Short list fix was too broad - update.
ilonatommy Apr 28, 2026
68e7b0c
Missing change for the previous commit.
ilonatommy Apr 28, 2026
c477199
Document deletion behavior - above the viewport will cause a jump, ot…
ilonatommy Apr 28, 2026
c355522
Change `ItemKey` -> `ItemComparer` to not break non-ref comparison it…
ilonatommy Apr 28, 2026
e9bb141
This test should be mirroring end-testing reengage test + add logging.
ilonatommy Apr 28, 2026
3cf4c5c
Missing renames.
ilonatommy Apr 28, 2026
f7b80b9
Variables can be merged into one.
ilonatommy Apr 28, 2026
badca25
Feedback: align test infra.
ilonatommy Apr 28, 2026
2b343f6
Feedback: default value for comparer + test for in-memory items prepe…
ilonatommy Apr 28, 2026
9ae62d6
Merge branch 'release/11.0-preview4' of https://github.com/dotnet/asp…
ilonatommy Apr 28, 2026
7f2e271
Stabilize preparations to test actions.
ilonatommy Apr 29, 2026
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
9 changes: 9 additions & 0 deletions src/Components/Analyzers/src/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,13 @@ internal static class DiagnosticDescriptors
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: CreateLocalizableResourceString(nameof(Resources.UseInvokeVoidAsyncForObjectReturn_Description)));

public static readonly DiagnosticDescriptor VirtualizeItemsProviderRequiresItemComparer = new(
"BL0011",
CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_Title)),
CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_Format)),
Usage,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_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 @@ -210,4 +210,13 @@
<data name="UseInvokeVoidAsyncForObjectReturn_Title" xml:space="preserve">
<value>Use InvokeVoidAsync instead of InvokeAsync&lt;object&gt;</value>
</data>
<data name="VirtualizeItemsProviderRequiresItemComparer_Description" xml:space="preserve">
<value>Without ItemComparer, the component cannot detect whether items were prepended or appended, causing the viewport to jump when items change dynamically.</value>
</data>
<data name="VirtualizeItemsProviderRequiresItemComparer_Format" xml:space="preserve">
<value>Virtualize uses 'ItemsProvider' without 'ItemComparer'. Set ItemComparer to an IEqualityComparer that identifies items by a unique key.</value>
</data>
<data name="VirtualizeItemsProviderRequiresItemComparer_Title" xml:space="preserve">
<value>Virtualize with ItemsProvider requires ItemComparer</value>
</data>
</root>
141 changes: 141 additions & 0 deletions src/Components/Analyzers/src/VirtualizeItemComparerAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

#nullable enable

namespace Microsoft.AspNetCore.Components.Analyzers;

/// <summary>
/// Analyzer that detects usage of Virtualize with ItemsProvider but without ItemComparer.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class VirtualizeItemComparerAnalyzer : DiagnosticAnalyzer
{
private const string VirtualizeTypeName = "Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize`1";
private const string RenderTreeBuilderTypeName = "Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder";

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer);

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);

context.RegisterCompilationStartAction(compilationContext =>
{
var virtualizeType = compilationContext.Compilation.GetTypeByMetadataName(VirtualizeTypeName);
var renderTreeBuilderType = compilationContext.Compilation.GetTypeByMetadataName(RenderTreeBuilderTypeName);

if (virtualizeType is null || renderTreeBuilderType is null)
{
return;
}

compilationContext.RegisterOperationBlockStartAction(blockContext =>
{
var componentStack = new Stack<ComponentState>();
var completedVirtualizeComponents = new List<ComponentState>();

blockContext.RegisterOperationAction(operationContext =>
{
var invocation = (IInvocationOperation)operationContext.Operation;
var targetMethod = invocation.TargetMethod;

if (!SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, renderTreeBuilderType))
{
return;
}

switch (targetMethod.Name)
{
case "OpenComponent":
if (targetMethod.IsGenericMethod && targetMethod.TypeArguments.Length == 1)
{
var typeArg = targetMethod.TypeArguments[0];
var originalDef = typeArg is INamedTypeSymbol namedType && namedType.IsGenericType
? namedType.OriginalDefinition
: typeArg;

if (SymbolEqualityComparer.Default.Equals(originalDef, virtualizeType))
{
componentStack.Push(new ComponentState { IsVirtualize = true });
}
else
{
componentStack.Push(new ComponentState { IsVirtualize = false });
}
}
else
{
componentStack.Push(new ComponentState { IsVirtualize = false });
}
break;

case "AddComponentParameter":
if (componentStack.Count > 0 && componentStack.Peek().IsVirtualize)
{
if (invocation.Arguments.Length >= 2)
{
var nameArg = invocation.Arguments[1];
if (nameArg.Value.ConstantValue.HasValue &&
nameArg.Value.ConstantValue.Value is string paramName)
{
var state = componentStack.Peek();
if (paramName == "ItemsProvider")
{
state.HasItemsProvider = true;
state.ItemsProviderLocation = invocation.Syntax.GetLocation();
}
else if (paramName == "ItemComparer")
{
state.HasItemComparer = true;
}
}
}
}
break;

case "CloseComponent":
if (componentStack.Count > 0)
{
var state = componentStack.Pop();
if (state.IsVirtualize)
{
completedVirtualizeComponents.Add(state);
}
}
break;
}
}, OperationKind.Invocation);

blockContext.RegisterOperationBlockEndAction(endContext =>
{
foreach (var state in completedVirtualizeComponents)
{
if (state.HasItemsProvider && !state.HasItemComparer && state.ItemsProviderLocation != null)
{
endContext.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer,
state.ItemsProviderLocation));
}
}
});
});
});
}

private sealed class ComponentState
{
public bool IsVirtualize { get; set; }
public bool HasItemsProvider { get; set; }
public bool HasItemComparer { get; set; }
public Location? ItemsProviderLocation { get; set; }
}
}
127 changes: 127 additions & 0 deletions src/Components/Analyzers/test/VirtualizeItemComparerAnalyzerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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 VirtualizeItemComparerAnalyzerTest : DiagnosticVerifier
{
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() => new VirtualizeItemComparerAnalyzer();

private static readonly string VirtualizeDeclarations = @"
namespace Microsoft.AspNetCore.Components.Rendering
{
public class RenderTreeBuilder
{
public void OpenComponent<TComponent>(int sequence) where TComponent : IComponent { }
public void AddComponentParameter(int sequence, string name, object value) { }
public void CloseComponent() { }
}
}

namespace Microsoft.AspNetCore.Components
{
public interface IComponent { }
}

namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
public class Virtualize<TItem> : Microsoft.AspNetCore.Components.IComponent
{
public object ItemsProvider { get; set; }
public object Items { get; set; }
public object ItemComparer { get; set; }
public float ItemSize { get; set; }
}

public delegate System.Threading.Tasks.ValueTask<ItemsProviderResult<TItem>> ItemsProviderDelegate<TItem>(ItemsProviderRequest request);

public struct ItemsProviderRequest { }
public struct ItemsProviderResult<TItem> { }
}
";

[Fact]
public void ItemsProviderWithoutItemComparer_ReportsDiagnostic()
{
var test = @"
namespace ConsoleApplication1
{
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web.Virtualization;

class TestComponent
{
void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.OpenComponent<Virtualize<string>>(0);
__builder.AddComponentParameter(1, ""ItemsProvider"", (object)null);
__builder.AddComponentParameter(2, ""ItemSize"", (object)50f);
__builder.CloseComponent();
}
}
}" + VirtualizeDeclarations;

VerifyCSharpDiagnostic(test,
new DiagnosticResult
{
Id = DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer.Id,
Message = "Virtualize uses 'ItemsProvider' without 'ItemComparer'. Set ItemComparer to an IEqualityComparer that identifies items by a unique key.",
Severity = DiagnosticSeverity.Warning,
Locations = new[]
{
new DiagnosticResultLocation("Test0.cs", 12, 17)
}
});
}

[Fact]
public void ItemsProviderWithItemComparer_NoDiagnostic()
{
var test = @"
namespace ConsoleApplication1
{
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web.Virtualization;

class TestComponent
{
void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.OpenComponent<Virtualize<string>>(0);
__builder.AddComponentParameter(1, ""ItemsProvider"", (object)null);
__builder.AddComponentParameter(2, ""ItemComparer"", (object)null);
__builder.CloseComponent();
}
}
}" + VirtualizeDeclarations;

VerifyCSharpDiagnostic(test);
}

[Fact]
public void ItemsCollectionWithoutItemComparer_NoDiagnostic()
{
var test = @"
namespace ConsoleApplication1
{
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web.Virtualization;

class TestComponent
{
void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.OpenComponent<Virtualize<string>>(0);
__builder.AddComponentParameter(1, ""Items"", (object)null);
__builder.CloseComponent();
}
}
}" + VirtualizeDeclarations;

VerifyCSharpDiagnostic(test);
}
}
Loading
Loading