diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml new file mode 100644 index 00000000..5045d38b --- /dev/null +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -0,0 +1,39 @@ +name: Virtualization benchmark probe + +on: + push: + branches: + - upstream/benchmarks-virtualized-select-selection-state + pull_request: + branches: + - dev + paths: + - 'benchmarks/**' + - '.github/workflows/virtualization-benchmark-probe.yml' + workflow_dispatch: + +jobs: + probe: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run upstream-to-PR1 structural probe + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef "e7020d49a824a371ab3f3450255f10d9cfad8783" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + + - name: Upload probe results + if: always() + uses: actions/upload-artifact@v4 + with: + name: virtualization-benchmark-probe + path: benchmark-results diff --git a/CodeBeamMudExtensions.slnx b/CodeBeamMudExtensions.slnx index 089d99fc..44298196 100644 --- a/CodeBeamMudExtensions.slnx +++ b/CodeBeamMudExtensions.slnx @@ -1,4 +1,7 @@ + + + diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs new file mode 100644 index 00000000..b2f89739 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs @@ -0,0 +1,23 @@ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using MudExtensions.Services; + +namespace MudExtensions.Benchmarks; + +internal static class BenchmarkBunitContext +{ + public static BunitContext Create() + { + var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddMudServices(options => + { + options.SnackbarConfiguration.ShowTransitionDuration = 0; + options.SnackbarConfiguration.HideTransitionDuration = 0; + options.PopoverOptions.CheckForPopoverProvider = false; + }); + context.Services.AddMudExtensions(); + return context; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj new file mode 100644 index 00000000..811ff202 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + false + MudExtensions.Benchmarks + + + + + + + + + + + + diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs new file mode 100644 index 00000000..91558b34 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudListExtended", "InitialRender")] +public class ListExtendedInitialRenderBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(10, 100, 1_000, 4_000)] + public int ItemCount { get; set; } + + [Params(false, true)] + public bool Virtualize { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [1, ItemCount]; + } + + [Benchmark] + public async Task RenderList() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render>(parameters => parameters + .Add(x => x.ItemCollection, _items) + .Add(x => x.Virtualize, Virtualize) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, _selectedValues)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs new file mode 100644 index 00000000..687cc98d --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs @@ -0,0 +1,10 @@ +using BenchmarkDotNet.Running; +using MudExtensions.Benchmarks; + +if (args.Length > 0 && string.Equals(args[0], "probe", StringComparison.OrdinalIgnoreCase)) +{ + return await ScaleProbe.RunAsync(args[1..]); +} + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +return 0; diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs new file mode 100644 index 00000000..91e312bd --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.Globalization; +using Bunit; + +namespace MudExtensions.Benchmarks; + +internal static class ScaleProbe +{ + public static async Task RunAsync(string[] args) + { + var outputPath = GetOption(args, "--output"); + var variant = Environment.GetEnvironmentVariable("BENCHMARK_VARIANT") ?? "current"; + + await WarmUpAsync(); + + var results = new List(); + foreach (var scenario in Scenarios()) + { + results.Add(await MeasureAsync(variant, scenario)); + } + + WriteTable(results); + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, ToCsv(results)); + Console.WriteLine($"\nWrote {fullPath}"); + } + + return 0; + } + + private static async Task MeasureAsync(string variant, ProbeScenario scenario) + { + var items = Enumerable.Range(1, scenario.ItemCount).Select(static value => (int?)value).ToList(); + var selectedValues = CreateSelectedValues(scenario.ItemCount, scenario.SelectedCount); + + await using var context = BenchmarkBunitContext.Create(); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + var retainedBefore = GC.GetTotalMemory(forceFullCollection: true); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + + var stopwatch = Stopwatch.StartNew(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, items) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.SelectCount, scenario.SelectCount) + .Add(x => x.Virtualize, scenario.Virtualize)); + stopwatch.Stop(); + + var allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + var retainedAfter = GC.GetTotalMemory(forceFullCollection: true); + + var shadowItemCount = cut.FindAll("div[style='display: none'] div.mud-list-item-extended").Count; + var selectItemComponentCount = cut.FindComponents>().Count; + var markupLength = cut.Markup.Length; + + return new( + variant, + scenario.ItemCount, + scenario.SelectCount, + selectedValues.Length, + scenario.Virtualize, + stopwatch.Elapsed.TotalMilliseconds, + allocatedAfter - allocatedBefore, + retainedAfter - retainedBefore, + cut.RenderCount, + shadowItemCount, + selectItemComponentCount, + markupLength); + } + + private static int?[] CreateSelectedValues(int itemCount, int selectedCount) + { + if (selectedCount <= 0) + { + return []; + } + + if (selectedCount == 1) + { + return [itemCount]; + } + + return Enumerable.Range(0, Math.Min(itemCount, selectedCount)) + .Select(index => (int?)(1 + (index * (itemCount - 1) / (Math.Min(itemCount, selectedCount) - 1)))) + .Distinct() + .ToArray(); + } + + private static IEnumerable Scenarios() + { + foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 }) + { + yield return new(itemCount, 1, 2, true); + } + + yield return new(4_000, 5, 2, true); + yield return new(4_000, 20, 2, true); + + foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 }) + { + yield return new(itemCount, 1, 2, false); + } + + foreach (var selectedCount in new[] { 1, 10, 30, 100 }) + { + yield return new(4_000, 1, selectedCount, true); + } + } + + private static async Task WarmUpAsync() + { + var items = Enumerable.Range(1, 10).Select(static value => (int?)value).ToList(); + await using var context = BenchmarkBunitContext.Create(); + context.Render(parameters => parameters + .Add(x => x.Items, items) + .Add(x => x.SelectedValues, new int?[] { 1, 10 }) + .Add(x => x.Virtualize, true)); + } + + private static void WriteTable(IEnumerable results) + { + Console.WriteLine("Variant Items Selects Selected Virt Time ms Allocated Retained* Renders ShadowItems Components Markup"); + foreach (var result in results) + { + Console.WriteLine( + $"{result.Variant,-9} {result.ItemCount,5} {result.SelectCount,7} {result.SelectedCount,8} {result.Virtualize,4} " + + $"{result.ElapsedMilliseconds,8:F2} {FormatBytes(result.AllocatedBytes),11} {FormatBytes(result.ApproxRetainedBytes),11} " + + $"{result.RenderCount,8} {result.ShadowItemCount,11} {result.SelectItemComponentCount,10} {result.MarkupLength,6}"); + } + + Console.WriteLine("\n* Retained is an approximate full-GC delta while the rendered component remains alive; use BenchmarkDotNet allocation/GC results for statistical comparisons."); + } + + private static string ToCsv(IEnumerable results) + { + var lines = new List + { + "Variant,ItemCount,SelectCount,SelectedCount,Virtualize,ElapsedMilliseconds,AllocatedBytes,ApproxRetainedBytes,RenderCount,ShadowItemCount,SelectItemComponentCount,MarkupLength" + }; + + lines.AddRange(results.Select(result => string.Join(',', + Csv(result.Variant), + result.ItemCount.ToString(CultureInfo.InvariantCulture), + result.SelectCount.ToString(CultureInfo.InvariantCulture), + result.SelectedCount.ToString(CultureInfo.InvariantCulture), + result.Virtualize.ToString(CultureInfo.InvariantCulture), + result.ElapsedMilliseconds.ToString("F4", CultureInfo.InvariantCulture), + result.AllocatedBytes.ToString(CultureInfo.InvariantCulture), + result.ApproxRetainedBytes.ToString(CultureInfo.InvariantCulture), + result.RenderCount.ToString(CultureInfo.InvariantCulture), + result.ShadowItemCount.ToString(CultureInfo.InvariantCulture), + result.SelectItemComponentCount.ToString(CultureInfo.InvariantCulture), + result.MarkupLength.ToString(CultureInfo.InvariantCulture)))); + + return string.Join(Environment.NewLine, lines) + Environment.NewLine; + } + + private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; + + private static string FormatBytes(long bytes) + { + var sign = bytes < 0 ? "-" : string.Empty; + var absolute = Math.Abs((double)bytes); + return absolute switch + { + >= 1024 * 1024 => $"{sign}{absolute / (1024 * 1024):F1} MB", + >= 1024 => $"{sign}{absolute / 1024:F1} KB", + _ => $"{bytes} B" + }; + } + + private static string? GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase)) + { + return args[index + 1]; + } + } + + return null; + } + + private sealed record ProbeScenario(int ItemCount, int SelectCount, int SelectedCount, bool Virtualize); + + private sealed record ProbeResult( + string Variant, + int ItemCount, + int SelectCount, + int SelectedCount, + bool Virtualize, + double ElapsedMilliseconds, + long AllocatedBytes, + long ApproxRetainedBytes, + int RenderCount, + int ShadowItemCount, + int SelectItemComponentCount, + int MarkupLength); +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs new file mode 100644 index 00000000..5b9bd78e --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace MudExtensions.Benchmarks; + +internal sealed class SelectBenchmarkHost : ComponentBase +{ + [Parameter, EditorRequired] + public ICollection Items { get; set; } = []; + + [Parameter, EditorRequired] + public IEnumerable SelectedValues { get; set; } = []; + + [Parameter] + public int SelectCount { get; set; } = 1; + + [Parameter] + public bool Virtualize { get; set; } = true; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + for (var index = 0; index < SelectCount; index++) + { + builder.OpenComponent>(0); + builder.SetKey(index); + builder.AddAttribute(1, nameof(MudSelectExtended.ItemCollection), Items); + builder.AddAttribute(2, nameof(MudSelectExtended.Virtualize), Virtualize); + builder.AddAttribute(3, nameof(MudSelectExtended.MultiSelection), true); + builder.AddAttribute(4, nameof(MudSelectExtended.SelectedValues), SelectedValues); + builder.CloseComponent(); + } + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs new file mode 100644 index 00000000..a3629960 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -0,0 +1,48 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "InitialRender")] +public class SelectInitialRenderBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + private string _expectedText = null!; + + [Params(10, 100, 1_000, 4_000)] + public int ItemCount { get; set; } + + [Params(false, true)] + public bool Virtualize { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [1, ItemCount]; + _expectedText = $"1, {ItemCount}"; + } + + [Benchmark] + public async Task RenderSelect() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, Virtualize)); + + cut.WaitForAssertion(() => + { + var value = cut.Find("input").GetAttribute("value"); + if (!string.Equals(value, _expectedText, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Expected settled select text '{_expectedText}', but found '{value}'."); + } + }); + + return cut.FindComponents>().Count; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs new file mode 100644 index 00000000..cc9d8e42 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs @@ -0,0 +1,36 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "Multiplicity")] +public class SelectMultiplicityBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(1, 5, 20)] + public int SelectCount { get; set; } + + [GlobalSetup] + public void Setup() + { + const int itemCount = 4_000; + _items = Enumerable.Range(1, itemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [17, 3_999]; + } + + [Benchmark] + public async Task RenderVirtualizedSelects() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.SelectCount, SelectCount) + .Add(x => x.Virtualize, true)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs new file mode 100644 index 00000000..68d9f219 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "SelectionDensity")] +public class SelectSelectionDensityBenchmarks +{ + private const int ItemCount = 4_000; + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(1, 2, 10, 30, 100)] + public int SelectedCount { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = Enumerable.Range(1, SelectedCount) + .Select(index => (int?)(1 + ((index - 1) * (ItemCount - 1) / Math.Max(1, SelectedCount - 1)))) + .Distinct() + .ToArray(); + } + + [Benchmark] + public async Task RenderVirtualizedSelect() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, true)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..d423a25d --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,86 @@ +# Virtualized selection benchmarks + +This companion benchmark harness provides reproducible performance evidence for upstream PR #648. + +PR #648 is the production fix. This benchmark work is optional and should not be accepted independently of that PR. + +## Pinned comparison + +The authoritative comparison is: + +- upstream `dev`: `7b5faf7ebb7666558d13c447313e9b09c92a110d` +- PR #648 candidate: `e7020d49a824a371ab3f3450255f10d9cfad8783` + +The runner deliberately pins both commits. PR2 itself contains benchmark infrastructure, so using PR2 `HEAD` as the fixed side would measure the wrong Git point. + +A normal merge of PR #648 is the simplest path because it preserves the candidate SHA. If PR #648 is squash-merged or rebase-merged, update `FixedRef` to the resulting upstream commit and rebase this companion branch before merging it. + +## What is measured + +The benchmark project uses bUnit to execute the real Blazor component lifecycle. + +The structural probe records: + +- hidden shadow-list DOM item count; +- materialized `MudSelectItemExtended` component count; +- bUnit render count; +- generated markup length; +- one-shot render time; +- total allocated bytes; +- an explicitly labelled approximate retained-memory delta after a forced full GC while the rendered component remains alive. + +The probe matrix includes: + +- 10, 100, 1,000 and 4,000 items; +- virtualized and non-virtualized controls; +- 1, 5 and 20 simultaneous selects; +- selection densities from 1 through 100 selected values. + +Direct `MudListExtended` BenchmarkDotNet cases remain available as a control. + +## CI + +The GitHub Actions workflow runs the structural probe against the two immutable commits and uploads the raw results plus generated comparison summary. + +Hosted-runner elapsed timings are illustrative because runner hardware and contention vary. Do not use elapsed-time thresholds as a merge gate. Structural component counts, allocation behavior and scaling shape are the stronger CI signals. + +The automated workflow intentionally does **not** run the current initial-render BenchmarkDotNet scenario. During development that scenario did not distinguish the known shadow-list pathology reliably enough to be useful headline evidence. + +## Running locally + +Requirements: + +- Git +- PowerShell 7+ +- .NET 10 SDK + +Run the pinned structural comparison: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly +``` + +Run the complete benchmark suite manually: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 +``` + +Run a quick BenchmarkDotNet smoke test: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 ` + -SkipProbe ` + -Quick ` + -BenchmarkFilter "*SelectInitialRenderBenchmarks*" +``` + +For any published evidence, pass explicit refs and retain `run-info.txt` plus each variant's `source.txt`. + +## Interpretation + +The important scaling property is that `Virtualize=true` must not cause `MudSelectExtended` to instantiate one hidden item component for every member of `ItemCollection` merely to retain selected-value presentation state. + +Large reductions in materialized component count and managed allocation are therefore directly meaningful. Absolute timings from shared CI are secondary evidence. + +This benchmark companion exists to make the performance claim behind PR #648 inspectable and repeatable; it is not required for the production fix itself. diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 new file mode 100644 index 00000000..304e2228 --- /dev/null +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -0,0 +1,251 @@ +[CmdletBinding()] +param( + [string]$BaselineRef = "7b5faf7ebb7666558d13c447313e9b09c92a110d", + [string]$FixedRef = "e7020d49a824a371ab3f3450255f10d9cfad8783", + [string]$ResultsDirectory, + [string]$BenchmarkFilter = "*", + [switch]$Quick, + [switch]$ProbeOnly, + [switch]$SkipProbe +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($ProbeOnly -and $SkipProbe) { + throw "ProbeOnly and SkipProbe cannot be used together." +} + +$repoRoot = (& git -C $PSScriptRoot rev-parse --show-toplevel).Trim() +if ($LASTEXITCODE -ne 0) { + throw "Unable to locate the repository root." +} + +if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) { + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $ResultsDirectory = Join-Path $repoRoot "BenchmarkDotNet.Artifacts/virtualized-list-selection-state/$timestamp" +} + +$ResultsDirectory = [IO.Path]::GetFullPath($ResultsDirectory) +New-Item -ItemType Directory -Path $ResultsDirectory -Force | Out-Null + +function Resolve-Commit([string]$ref) { + $sha = (& git -C $repoRoot rev-parse "$ref^{commit}").Trim() + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve '$ref'." + } + + return $sha +} + +function Invoke-Checked([string]$command, [string[]]$arguments) { + & $command @arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed ($LASTEXITCODE): $command $($arguments -join ' ')" + } +} + +$baselineSha = Resolve-Commit $BaselineRef +$fixedSha = Resolve-Commit $FixedRef +$benchmarksSource = Join-Path $repoRoot "benchmarks" +$workRoot = Join-Path ([IO.Path]::GetTempPath()) "CodeBeamMudExtensionsBenchmarks-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $workRoot -Force | Out-Null + +$previousCi = $env:CI +$previousVariant = $env:BENCHMARK_VARIANT +$worktrees = [System.Collections.Generic.List[string]]::new() + +function Invoke-Variant([string]$name, [string]$sha) { + $worktree = Join-Path $workRoot $name + $worktrees.Add($worktree) + + Write-Host "`n=== $name ($sha) ===" -ForegroundColor Cyan + Invoke-Checked git @("-C", $repoRoot, "worktree", "add", "--detach", $worktree, $sha) + + $targetBenchmarks = Join-Path $worktree "benchmarks" + New-Item -ItemType Directory -Path $targetBenchmarks -Force | Out-Null + Copy-Item -Path (Join-Path $benchmarksSource "*") -Destination $targetBenchmarks -Recurse -Force + + $variantResults = Join-Path $ResultsDirectory $name + New-Item -ItemType Directory -Path $variantResults -Force | Out-Null + + $project = Join-Path $targetBenchmarks "CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj" + $probeOutput = Join-Path $variantResults "scale-probe.csv" + $bdnArtifacts = Join-Path $variantResults "BenchmarkDotNet.Artifacts" + + $env:CI = "true" + $env:BENCHMARK_VARIANT = $name + + (& dotnet --info) | Out-File -FilePath (Join-Path $variantResults "dotnet-info.txt") -Encoding utf8 + (& git -C $worktree show -s --format="%H%n%ad%n%s" --date=iso-strict HEAD) | + Out-File -FilePath (Join-Path $variantResults "source.txt") -Encoding utf8 + + if (-not $SkipProbe) { + Invoke-Checked dotnet @( + "run", "--project", $project, "--configuration", "Release", "--", + "probe", "--output", $probeOutput + ) + } + + if ($ProbeOnly) { + return + } + + $benchmarkArguments = [System.Collections.Generic.List[string]]::new() + @( + "run", "--project", $project, "--configuration", "Release", "--", + "--filter", $BenchmarkFilter, + "--artifacts", $bdnArtifacts, + "--exporters", "GitHub", "CSV", "JSON", + "--allStats", + "--join" + ) | ForEach-Object { $benchmarkArguments.Add($_) } + + if ($Quick) { + $benchmarkArguments.Add("--job") + $benchmarkArguments.Add("short") + } + + Invoke-Checked dotnet $benchmarkArguments.ToArray() +} + +function Format-SummaryBytes([double]$bytes) { + $absolute = [Math]::Abs($bytes) + if ($absolute -ge 1GB) { return "{0:F2} GB" -f ($bytes / 1GB) } + if ($absolute -ge 1MB) { return "{0:F2} MB" -f ($bytes / 1MB) } + if ($absolute -ge 1KB) { return "{0:F1} KB" -f ($bytes / 1KB) } + return "{0:F0} B" -f $bytes +} + +function Get-ProbeRow([string]$variant, [int]$itemCount, [int]$selectCount, [int]$selectedCount, [bool]$virtualize) { + $path = Join-Path $ResultsDirectory "$variant/scale-probe.csv" + if (-not (Test-Path $path)) { + return $null + } + + return Import-Csv $path | + Where-Object { + [int]$_.ItemCount -eq $itemCount -and + [int]$_.SelectCount -eq $selectCount -and + [int]$_.SelectedCount -eq $selectedCount -and + [bool]::Parse($_.Virtualize) -eq $virtualize + } | + Select-Object -First 1 +} + +function Get-ProbeValue($row, [string]$property) { + if ($null -eq $row) { + return $null + } + + return [double]$row.PSObject.Properties[$property].Value +} + +function Format-ProbeValue([string]$property, $value) { + if ($null -eq $value) { + return "—" + } + + switch ($property) { + "ElapsedMilliseconds" { return "{0:F2} ms" -f $value } + "AllocatedBytes" { return Format-SummaryBytes $value } + "ApproxRetainedBytes" { return Format-SummaryBytes $value } + default { return "{0:N0}" -f $value } + } +} + +function Format-Reduction($baselineValue, $fixedValue) { + if ($null -eq $baselineValue -or $null -eq $fixedValue -or $fixedValue -le 0) { + return "—" + } + + $factor = $baselineValue / $fixedValue + if ($factor -ge 1) { + return "{0:F1}x lower" -f $factor + } + + return "{0:F1}x higher" -f (1 / $factor) +} + +function Write-ProbeComparisonSummary { + if ($SkipProbe) { + return + } + + $scenarios = @( + @{ Name = "4,000 items x 1 select"; Items = 4000; Selects = 1; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items x 5 selects"; Items = 4000; Selects = 5; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items x 20 selects"; Items = 4000; Selects = 20; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items, 100 selected"; Items = 4000; Selects = 1; Selected = 100; Virtualize = $true } + ) + + $metrics = @( + @{ Name = "Materialized components"; Property = "SelectItemComponentCount" }, + @{ Name = "Allocated"; Property = "AllocatedBytes" }, + @{ Name = "Approx. retained"; Property = "ApproxRetainedBytes" }, + @{ Name = "One-shot elapsed"; Property = "ElapsedMilliseconds" } + ) + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("# Virtualized selection comparison") + $lines.Add("") + $lines.Add("Generated from the same benchmark harness against immutable Git commits.") + $lines.Add("") + $lines.Add("- Upstream baseline: `$baselineSha` (`$BaselineRef`)") + $lines.Add("- PR1 candidate: `$fixedSha` (`$FixedRef`)") + $lines.Add("") + $lines.Add("Probe elapsed/retained values are diagnostic. Shared-runner timing should be treated as illustrative; structural component counts and allocation/scaling shape are the stronger CI evidence.") + $lines.Add("") + $lines.Add("| Scenario | Metric | Upstream baseline | PR1 candidate | Baseline -> PR1 |") + $lines.Add("| --- | --- | ---: | ---: | ---: |") + + foreach ($scenario in $scenarios) { + $baselineRow = Get-ProbeRow "baseline" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + $fixedRow = Get-ProbeRow "fixed" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + + foreach ($metric in $metrics) { + $baselineValue = Get-ProbeValue $baselineRow $metric.Property + $fixedValue = Get-ProbeValue $fixedRow $metric.Property + $lines.Add("| $($scenario.Name) | $($metric.Name) | $(Format-ProbeValue $metric.Property $baselineValue) | $(Format-ProbeValue $metric.Property $fixedValue) | $(Format-Reduction $baselineValue $fixedValue) |") + } + } + + $summaryPath = Join-Path $ResultsDirectory "comparison-summary.md" + $lines | Out-File -FilePath $summaryPath -Encoding utf8 + Write-Host "Wrote $summaryPath" +} + +try { + @( + "Baseline ref: $BaselineRef", + "Baseline SHA: $baselineSha", + "PR1 ref: $FixedRef", + "PR1 SHA: $fixedSha", + "Started: $([DateTimeOffset]::Now.ToString('O'))", + "Benchmark filter: $BenchmarkFilter", + "Quick: $Quick", + "Probe only: $ProbeOnly", + "Skip probe: $SkipProbe" + ) | Out-File -FilePath (Join-Path $ResultsDirectory "run-info.txt") -Encoding utf8 + + Invoke-Variant "baseline" $baselineSha + Invoke-Variant "fixed" $fixedSha + Write-ProbeComparisonSummary + + Write-Host "`nBenchmark results: $ResultsDirectory" -ForegroundColor Green +} +finally { + $env:CI = $previousCi + $env:BENCHMARK_VARIANT = $previousVariant + + foreach ($worktree in $worktrees) { + if (Test-Path $worktree) { + & git -C $repoRoot worktree remove --force $worktree | Out-Null + } + } + + & git -C $repoRoot worktree prune | Out-Null + if (Test-Path $workRoot) { + Remove-Item -Path $workRoot -Recurse -Force + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs new file mode 100644 index 00000000..0245d4f2 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MudExtensions +{ + public partial class MudListItemExtended + { + /// + /// Re-applies selection state whenever a list item receives parameters. + /// Virtualized lists can reuse an existing component instance for a different value, + /// so selection cannot rely only on registration/scroll timing. + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (MudListExtended == null) + { + return; + } + + var comparer = MudListExtended.Comparer ?? EqualityComparer.Default; + + _selected = MudListExtended.MultiSelection + ? MudListExtended.SelectedValues?.Contains(Value, comparer) == true + : comparer.Equals(MudListExtended.SelectedValue, Value); + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs new file mode 100644 index 00000000..078ee741 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MudExtensions +{ + public partial class MudSelectExtended + { + /// + /// Returns the values that must be materialized by the hidden list. + /// Non-virtualized selects preserve the existing full item registry behavior; + /// virtualized selects only materialize selected values that exist in the current item collection. + /// + protected ICollection? GetShadowItemCollection() + { + if (ItemCollection == null || !Virtualize) + { + return ItemCollection; + } + + var selectedValues = SelectedValues?.ToHashSet(_comparer) ?? new HashSet(_comparer); + return [.. ItemCollection.Where(selectedValues.Contains)]; + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.VirtualizedSelectionText.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.VirtualizedSelectionText.cs new file mode 100644 index 00000000..3261f851 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.VirtualizedSelectionText.cs @@ -0,0 +1,19 @@ +namespace MudExtensions +{ + public partial class MudSelectExtended + { + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + // SelectedValues is a synchronous parameter setter. Its existing async text + // updates cannot be awaited there, so reconcile the displayed text once + // the incoming parameter set has been applied. Do not depend on the newly + // selected shadow item having rendered yet. + if (Virtualize && MultiSelection && ItemCollection is not null) + { + await UpdateTextPropertyAsync(false); + } + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 18d6f7a5..482cc2ff 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor @@ -1,4 +1,4 @@ -@namespace MudExtensions +@namespace MudExtensions @typeparam T @inherits MudBaseInputExtended @@ -159,12 +159,12 @@ - @*To Be Discussed as Second ShadowItems: Needed to render directly into select to has initial values*@ + @*The hidden list only materializes selected ItemCollection values. It keeps selected item components available for presenters without defeating virtualization.*@
- ?)_shadowParent)?.UnregisterShadowItem(this); } catch (Exception) { } diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs new file mode 100644 index 00000000..227909c7 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs @@ -0,0 +1,66 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class ListExtendedVirtualizationTests : BunitTest + { + [Test] + public void ReusedListItem_ReappliesSelectionFromSelectedValues() + { + var selectedValues = new int?[] { 42 }; + var cut = RenderList(selectedValues, value: 1); + + var initialItem = cut.FindComponent>(); + initialItem.Instance.IsSelected.Should().BeFalse(); + + cut.Render(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.ChildContent, RenderItem(42))); + + var reusedItem = cut.FindComponent>(); + ReferenceEquals(initialItem.Instance, reusedItem.Instance).Should().BeTrue(); + reusedItem.Instance.IsSelected.Should().BeTrue(); + } + + [Test] + public void MaterializedListItem_UsesConfiguredComparerForSelection() + { + var cut = Context.Render>(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.Comparer, new AbsoluteValueComparer()) + .Add(x => x.SelectedValues, new int?[] { 42 }) + .Add(x => x.ChildContent, RenderItem(-42))); + + cut.FindComponent>().Instance.IsSelected.Should().BeTrue(); + } + + private IRenderedComponent> RenderList(IEnumerable selectedValues, int? value) + { + return Context.Render>(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.ChildContent, RenderItem(value))); + } + + private static RenderFragment RenderItem(int? value) => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(MudListItemExtended.Value), value); + builder.CloseComponent(); + }; + + private sealed class AbsoluteValueComparer : IEqualityComparer + { + public bool Equals(int? x, int? y) => Math.Abs(x ?? 0) == Math.Abs(y ?? 0); + + public int GetHashCode(int? obj) => Math.Abs(obj ?? 0).GetHashCode(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs new file mode 100644 index 00000000..1d43e2ac --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs @@ -0,0 +1,43 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationScaleTests : BunitTest + { + [TestCase(10)] + [TestCase(100)] + [TestCase(1_000)] + [TestCase(4_000)] + public void ShadowItemCount_IsBoundedBySelection_NotCollectionSize(int itemCount) + { + var items = Enumerable.Range(1, itemCount).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 1, itemCount }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(2); + } + + [Test] + public void NonVirtualizedItemCollection_PreservesFullRegisteredItemSet() + { + var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 1, 100 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, false) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.Instance.Items.Should().HaveCount(items.Count); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs new file mode 100644 index 00000000..ab6a51c8 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs @@ -0,0 +1,49 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationSingleSelectionTests : BunitTest + { + [Test] + public void VirtualizedItemCollection_InitializedSingleSelectionUsesOneShadowItem() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + } + + [Test] + public void VirtualizedItemCollection_OffscreenValueSupportsItemContentPresenter() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + RenderFragment> template = item => builder => + builder.AddContent(0, $"Selected item {item.Value}"); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.ValuePresenter, ValuePresenter.ItemContent) + .Add(x => x.ItemTemplate, template) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Markup.Should().Contain("Selected item 3999")); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs new file mode 100644 index 00000000..83a959b3 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs @@ -0,0 +1,42 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationStrictTests : BunitTest + { + [Test] + public void StrictMode_ValidOffscreenInitialValue_RemainsRepresentable() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Strict, true) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + } + + [Test] + public void StrictMode_ValueOutsideItemCollection_IsNotAddedToShadowList() + { + var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Strict, true) + .Add(x => x.Value, 999)); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Should().BeEmpty(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs new file mode 100644 index 00000000..1c0337c7 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -0,0 +1,112 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationTests : BunitTest + { + [Test] + public void VirtualizedItemCollection_ShadowListContainsOnlySelectedItems() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 17, 3_999 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(2); + cut.Instance.SelectedValues.Should().BeEquivalentTo(selectedValues); + } + + [Test] + public void VirtualizedItemCollection_InitializedMultiSelectionStillProducesText() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 17, 3_999 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("17, 3999")); + } + + [Test] + public void VirtualizedItemCollection_ChangedSelectionReplacesShadowItems() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, new int?[] { 17 })); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("17")); + + cut.Render(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, new int?[] { 3_999 })); + + // Assert each layer independently so failures identify whether parameters, hidden + // components or the input presenter stopped following the selection. + cut.WaitForAssertion(() => + cut.Instance.SelectedValues.Should().BeEquivalentTo(new int?[] { 3_999 })); + + cut.WaitForAssertion(() => + { + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + shadowList.TextContent.Should().Contain("3999"); + shadowList.TextContent.Should().NotContain("17"); + }); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + } + + [Test] + public void VirtualizedItemCollection_ShadowListRespectsComparer() + { + var items = new List + { + new(1, "One"), + new(2, "Two"), + new(3, "Three") + }; + var selectedValues = new TestValue?[] { new(2, "Different instance") }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.Comparer, new TestValueComparer()) + .Add(x => x.ToStringFunc, value => value?.Name)); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + shadowList.TextContent.Should().Contain("Two"); + } + + private sealed record TestValue(int Id, string Name); + + private sealed class TestValueComparer : IEqualityComparer + { + public bool Equals(TestValue? x, TestValue? y) => x?.Id == y?.Id; + + public int GetHashCode(TestValue? obj) => obj?.Id.GetHashCode() ?? 0; + } + } +}