diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml new file mode 100644 index 00000000..165129a8 --- /dev/null +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -0,0 +1,64 @@ +name: Virtualization benchmark probe + +on: + push: + branches: + - benchmarks/virtualized-list-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 three-stage structural probe + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -Stage1Ref "76b806ec54cdb43c9b5171bbbd10d4759014583f" -FixedRef "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" -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 + + quick-benchmark: + 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 authoritative upstream-to-candidate BenchmarkDotNet comparison + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + + - name: Upload BenchmarkDotNet results + if: always() + uses: actions/upload-artifact@v4 + with: + name: virtualization-benchmark-quick + path: benchmark-results diff --git a/.github/workflows/virtualization-validation.yml b/.github/workflows/virtualization-validation.yml new file mode 100644 index 00000000..6718098b --- /dev/null +++ b/.github/workflows/virtualization-validation.yml @@ -0,0 +1,41 @@ +name: Virtualization validation + +on: + push: + branches: + - fix/virtualized-list-selection-state + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + run: dotnet restore + + - name: Build + shell: bash + run: | + set -o pipefail + dotnet build --no-restore 2>&1 | tee build.log + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: virtualization-build-log + path: build.log + + - name: Test + run: dotnet test --configuration Release -f net10.0 --collect:"XPlat Code Coverage" 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..391bdcb4 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,170 @@ +# Virtualized selection benchmarks + +This benchmark harness measures the `MudSelectExtended` / `MudListExtended` virtualization scenarios behind CodeBeamOrg/CodeBeam.MudBlazor.Extensions issues #493 and #608 and the initialization regression fixed by #583. + +The project deliberately lives outside the unit-test project. NUnit + bUnit tests remain the correctness/regression suite; BenchmarkDotNet is used only for repeatable performance measurements. + +## What is measured + +`CodeBeam.MudBlazor.Extensions.Benchmarks` uses bUnit to execute the real Blazor component lifecycle and BenchmarkDotNet to measure elapsed time, managed allocations, and Gen 0/1/2 collection rates. + +The benchmark matrix covers: + +- direct `MudListExtended` initial renders with 10, 100, 1,000 and 4,000 items, with virtualization both off and on; +- initial `MudSelectExtended` render with 10, 100, 1,000 and 4,000 items; +- `Virtualize=false` and `Virtualize=true` select renders; +- 1, 5 and 20 simultaneous virtualized selects with 4,000 items each; +- 1, 2, 10, 30 and 100 selected values from a 4,000-item collection. + +Each benchmark invocation creates and disposes its own bUnit context. This keeps iterations isolated and makes allocation measurements reproducible. The constant bUnit setup cost is intentionally present in both baseline and fixed runs; the dataset-size scaling is the primary comparison. + +A separate `probe` mode records Blazor-specific shape metrics which BenchmarkDotNet does not understand natively: + +- hidden shadow-list DOM item count; +- materialized `MudSelectItemExtended` component count; +- bUnit render count; +- generated markup length; +- one-shot render time and total allocated bytes; +- an explicitly labelled **approximate** retained-memory delta after a forced full GC while the rendered component remains alive. + +The probe is diagnostic evidence, not a substitute for BenchmarkDotNet statistics. + + +## Three-stage evidence + +The runner can optionally measure an intermediate Git graph point as well as the normal baseline and candidate: + +1. **Upstream baseline** — the exact upstream `dev` commit used as the reference. +2. **Stage 1 / first fix** — `76b806ec54cdb43c9b5171bbbd10d4759014583f`, the historical implementation point that the benchmark harness originally treated as its fixed side. +3. **Current candidate / HEAD** — the current implementation being evaluated. + +Run the three-stage structural probe with: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 \ + -ProbeOnly \ + -BaselineRef 7b5faf7ebb7666558d13c447313e9b09c92a110d \ + -Stage1Ref 76b806ec54cdb43c9b5171bbbd10d4759014583f \ + -FixedRef HEAD +``` + +When `Stage1Ref` is supplied, the runner writes `comparison-summary.md` alongside the raw CSV files. This is intended to document how the implementation evolved; it is **not** the merge/regression baseline. + +The authoritative final comparison remains **upstream `dev` -> current HEAD**. CI therefore includes Stage 1 in the structural probe, but the focused BenchmarkDotNet regression job compares only upstream baseline versus HEAD. + +## Run baseline and fixed code on the same workstation + +From the benchmark branch: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 +``` + +The script creates temporary detached Git worktrees for the requested graph points, copies the exact same benchmark harness into each, and runs the variants sequentially on the same machine. It records every resolved SHA and `dotnet --info` alongside the output. `Stage1Ref` is optional; without it the runner behaves as the normal two-way baseline/candidate comparison. + +For a faster smoke run: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 -Quick +``` + +`-Quick` asks BenchmarkDotNet to use its short job. Do not use quick-run numbers as final PR evidence when a normal run is available. + +Results are written below `BenchmarkDotNet.Artifacts/virtualized-list-selection-state//` unless `-ResultsDirectory` is supplied. + +## Choosing what to run + +The benchmark harness is intentionally opt-in. It should complement, not replace, the normal correctness suite. + +Recommended policy: + +| Context | Recommended command | Purpose | +| --- | --- | --- | +| Every PR/build | normal unit/regression tests | Required correctness gate | +| Performance-sensitive PR, manual CI request, or `perf-benchmark` label | `./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly` | Fast structural/scaling check | +| Manual CI or developer smoke test | `./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BenchmarkFilter "*SelectInitialRenderBenchmarks*"` | Short BenchmarkDotNet comparison | +| Final performance evidence | `./benchmarks/run-virtualization-benchmarks.ps1 -BaselineRef -FixedRef ` | Full same-machine before/after run | + +Do not make elapsed-time thresholds from shared hosted runners a required merge gate. Runner hardware and contention vary. Component counts, shadow-item counts, allocations and scaling shape are more useful CI signals; final timing evidence should come from repeated runs on the same workstation or a stable dedicated runner. + +### Developer workstation + +Requirements are Git, PowerShell 7+ and the .NET 10 SDK. Run from the repository root. + +Fast structural probe: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly +``` + +Focused smoke benchmark: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 ` + -SkipProbe ` + -Quick ` + -BenchmarkFilter "*SelectInitialRenderBenchmarks*" +``` + +Full before/after evidence against an explicit baseline: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 ` + -BaselineRef ` + -FixedRef HEAD +``` + +`FixedRef` defaults to `HEAD`, so a developer can simply check out the candidate branch and run the script. For published evidence, pass both refs explicitly and retain the generated `run-info.txt` and per-variant `source.txt` files. + +### Conditional CI example + +A downstream GitHub Actions pipeline can keep benchmarks disabled by default and enable them for a manual dispatch or a PR label: + +```yaml +- name: Virtualized selection performance probe + if: > + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'perf-benchmark') + shell: pwsh + run: > + ./benchmarks/run-virtualization-benchmarks.ps1 + -ProbeOnly + -BaselineRef "${{ github.event.pull_request.base.sha }}" + -FixedRef "${{ github.sha }}" + -ResultsDirectory "${{ github.workspace }}/benchmark-results" + +- name: Upload benchmark evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: virtualization-benchmark-results + path: benchmark-results +``` + +The same pattern works in other CI systems: put the script invocation behind an explicit pipeline variable/parameter such as `RUN_VIRTUALIZATION_BENCHMARKS=true`, a performance label, a changed-path rule, or a manually selected stage. The script itself stays CI-agnostic. +## Interpretation + +The key expected scaling characteristic is that a virtualized select should not instantiate an item component for every member of `ItemCollection` merely to retain selected-value presentation state. Large changes in allocated bytes, retained component count and GC pressure are therefore meaningful. Small absolute timing differences for 10- or 100-item collections should be treated cautiously. + +The direct-list cases are intentionally included as a control: the `MudListExtended` selection-state correction should preserve reasonable list-render scaling rather than trading the select improvement for a list regression. + +GitHub-hosted runners are useful for build validation and preliminary measurements, but final before/after numbers should preferably come from repeated runs on the same workstation because hosted-runner hardware and contention can vary. + +## Reference baseline used by this PR + +For the final PR1/PR2 comparison in this branch, the baseline is pinned to upstream `CodeBeamOrg/CodeBeam.MudBlazor.Extensions` `dev` at: + +`7b5faf7ebb7666558d13c447313e9b09c92a110d` + +This is the upstream `dev` commit immediately before the PR1 candidate branch diverges for this comparison. The fixed side defaults to the checked-out benchmark branch `HEAD`, which contains the exact PR1 implementation under test plus the benchmark harness. + +For historical three-stage evidence, Stage 1 is pinned to: + +`76b806ec54cdb43c9b5171bbbd10d4759014583f` + +Stage 1 is supplemental evidence only. Performance regression conclusions and final BenchmarkDotNet numbers must compare the upstream baseline directly with the candidate `HEAD`. + +The runner records all resolved SHAs in `run-info.txt` and each variant's `source.txt`, so any published result can be traced to an exact point in the Git graph. +## Reproducibility + +Final before/after results must be produced from a benchmark branch containing the exact PR1 candidate being evaluated. Record the candidate commit SHA with the results so benchmark evidence cannot drift from the implementation under review. diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 new file mode 100644 index 00000000..ad16ecdd --- /dev/null +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -0,0 +1,278 @@ +[CmdletBinding()] +param( + [string]$BaselineRef = "7b5faf7ebb7666558d13c447313e9b09c92a110d", + [string]$Stage1Ref, + [string]$FixedRef = "HEAD", + [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 +$stage1Sha = if ([string]::IsNullOrWhiteSpace($Stage1Ref)) { $null } else { Resolve-Commit $Stage1Ref } +$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 graph points.") + $lines.Add("") + $lines.Add("- Upstream baseline: `$baselineSha` (`$BaselineRef`)") + if ($null -ne $stage1Sha) { + $lines.Add("- Stage 1: `$stage1Sha` (`$Stage1Ref`)") + } + $lines.Add("- Current candidate: `$fixedSha` (`$FixedRef`)") + $lines.Add("") + $lines.Add("The Stage 1 column is historical evidence only. The authoritative regression comparison is upstream baseline -> current candidate.") + $lines.Add("Probe elapsed/retained values are diagnostic; use BenchmarkDotNet baseline -> candidate results for final timing/allocation evidence.") + $lines.Add("") + + if ($null -ne $stage1Sha) { + $lines.Add("| Scenario | Metric | Upstream baseline | Stage 1 | Current candidate | Baseline -> candidate |") + $lines.Add("| --- | --- | ---: | ---: | ---: | ---: |") + } + else { + $lines.Add("| Scenario | Metric | Upstream baseline | Current candidate | Baseline -> candidate |") + $lines.Add("| --- | --- | ---: | ---: | ---: |") + } + + foreach ($scenario in $scenarios) { + $baselineRow = Get-ProbeRow "baseline" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + $stage1Row = if ($null -ne $stage1Sha) { Get-ProbeRow "stage1" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize } else { $null } + $fixedRow = Get-ProbeRow "fixed" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + + foreach ($metric in $metrics) { + $baselineValue = Get-ProbeValue $baselineRow $metric.Property + $stage1Value = Get-ProbeValue $stage1Row $metric.Property + $fixedValue = Get-ProbeValue $fixedRow $metric.Property + + if ($null -ne $stage1Sha) { + $lines.Add("| $($scenario.Name) | $($metric.Name) | $(Format-ProbeValue $metric.Property $baselineValue) | $(Format-ProbeValue $metric.Property $stage1Value) | $(Format-ProbeValue $metric.Property $fixedValue) | $(Format-Reduction $baselineValue $fixedValue) |") + } + else { + $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", + "Stage 1 ref: $Stage1Ref", + "Stage 1 SHA: $stage1Sha", + "Fixed ref: $FixedRef", + "Fixed 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 + if ($null -ne $stage1Sha) { + Invoke-Variant "stage1" $stage1Sha + } + 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; + } + } +}