From 8c53be1c2e8eb5fa88fb880664378d6cc9fc0df4 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 22:57:27 +1000 Subject: [PATCH 01/61] Fix virtualized list item selection state --- .../MudListItemExtended.SelectionState.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs 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); + } + } +} From 71ab450eec42f72ba8ef0f7416bb3fe9582e55ac Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 22:57:43 +1000 Subject: [PATCH 02/61] Limit select shadow items to selected values --- .../MudSelectExtended.ShadowItems.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs 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..0cfd708a --- /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 only selected values that are present in the current item collection. + /// The hidden list exists to materialize selected item components for presentation; + /// it must not materialize the entire collection when the visible list is virtualized. + /// + protected ICollection? GetShadowItemCollection() + { + if (ItemCollection == null) + { + return null; + } + + var selectedValues = SelectedValues?.ToHashSet(_comparer) ?? new HashSet(_comparer); + return [.. ItemCollection.Where(selectedValues.Contains)]; + } + } +} From 4fbee31e277fbb8c3b2fafbbb28e80869e74dcf5 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 22:58:20 +1000 Subject: [PATCH 03/61] Keep select shadow list bounded to selection --- .../Components/SelectExtended/MudSelectExtended.razor | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 18d6f7a5..31a5ae1f 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 @@ -29,7 +29,7 @@ Variant="@Variant" HasAdornmentStart="@(AdornmentStart != null)" HasAdornmentEnd="true" Value="@(Strict && !IsValueInList ? null : ReadText)" Underline="@Underline" - Disabled="@GetDisabledState()" ReadOnly="true" Error="@ErrorState.Value" ErrorId="@ErrorIdState.Value" + Disabled="@GetDisabledState()" ReadOnly="true" Error="@ErrorState.Value" ErrorId="@ErrorState.Value" Clearable="@(Clearable && !GetReadOnlyState())" OnClearButtonClick="(async (e) => await SelectClearButtonClickHandlerAsync(e))" @attributes="GetUserAttributesForHiddenInput()" OnBlur="@OnLostFocus" ShrinkLabel="@(AdornmentStart != null || ShrinkLabel)" Typo="@Typo" ShowVisualiser="true" DataVisualiserStyle="min-height: 1.1876em; padding-right: 0px;"> @@ -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.*@
Date: Tue, 15 Sep 2026 22:58:51 +1000 Subject: [PATCH 04/61] Restore select error identifier binding --- .../Components/SelectExtended/MudSelectExtended.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 31a5ae1f..1f480daf 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor @@ -29,7 +29,7 @@ Variant="@Variant" HasAdornmentStart="@(AdornmentStart != null)" HasAdornmentEnd="true" Value="@(Strict && !IsValueInList ? null : ReadText)" Underline="@Underline" - Disabled="@GetDisabledState()" ReadOnly="true" Error="@ErrorState.Value" ErrorId="@ErrorState.Value" + Disabled="@GetDisabledState()" ReadOnly="true" Error="@ErrorState.Value" ErrorId="@ErrorIdState.Value" Clearable="@(Clearable && !GetReadOnlyState())" OnClearButtonClick="(async (e) => await SelectClearButtonClickHandlerAsync(e))" @attributes="GetUserAttributesForHiddenInput()" OnBlur="@OnLostFocus" ShrinkLabel="@(AdornmentStart != null || ShrinkLabel)" Typo="@Typo" ShowVisualiser="true" DataVisualiserStyle="min-height: 1.1876em; padding-right: 0px;"> From fad99661e02f8e03d43836c48d16c24d6cb1090f Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:00:09 +1000 Subject: [PATCH 05/61] Add virtualized list selection regression tests --- .../ListExtendedVirtualizationTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs 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(); + } + } +} From 39857e4c95651c6143529015c64181134cfe3b0b Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:00:24 +1000 Subject: [PATCH 06/61] Add select virtualization regression tests --- .../SelectExtendedVirtualizationTests.cs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs 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..700eda08 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -0,0 +1,76 @@ +using AwesomeAssertions; +using Bunit; +using MudExtensions.UnitTests.Extensions; + +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.Instance.GetState(x => x.Text).Should().Be("17, 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.Comparer, new TestValueComparer()) + .Add(x => x.ToStringFunc, value => value?.Name) + .Add(x => x.SelectedValues, selectedValues)); + + 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; + } + } +} From 98f325eec25b603c6d95f5d0747a7d53e8f52cc6 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:00:51 +1000 Subject: [PATCH 07/61] Protect shadow list scaling behavior --- .../SelectExtendedVirtualizationScaleTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs 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..dcd84b69 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs @@ -0,0 +1,28 @@ +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); + } + } +} From 89d94c67b0ad5a5fba242f6b4b1aff5647232236 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:01:13 +1000 Subject: [PATCH 08/61] Add strict virtualization regression coverage --- ...SelectExtendedVirtualizationStrictTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs 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..338c693b --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs @@ -0,0 +1,43 @@ +using AwesomeAssertions; +using Bunit; +using MudExtensions.UnitTests.Extensions; + +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.Instance.GetState(x => x.Text).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(); + } + } +} From 3ee1d3e53de9685817fe5b737333dc0798d212b8 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:01:30 +1000 Subject: [PATCH 09/61] Cover initialized single selection virtualization --- ...endedVirtualizationSingleSelectionTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs 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..72a9b8df --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs @@ -0,0 +1,27 @@ +using AwesomeAssertions; +using Bunit; +using MudExtensions.UnitTests.Extensions; + +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.Instance.GetState(x => x.Text).Should().Be("3999")); + + var shadowList = cut.Find("div[style='display: none']"); + shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1); + } + } +} From 9651a3b9b7636bcc7bfdcef25a77f0ffe1077315 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:02:43 +1000 Subject: [PATCH 10/61] Add temporary virtualization validation workflow --- .../workflows/virtualization-validation.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/virtualization-validation.yml diff --git a/.github/workflows/virtualization-validation.yml b/.github/workflows/virtualization-validation.yml new file mode 100644 index 00000000..59c99415 --- /dev/null +++ b/.github/workflows/virtualization-validation.yml @@ -0,0 +1,31 @@ +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 + run: dotnet build --no-restore + + - name: Test + run: dotnet test --configuration Release -f net10.0 --collect:"XPlat Code Coverage" From 3209ffff744b0f4595b7928ab680b8a5840f34b1 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:12:03 +1000 Subject: [PATCH 11/61] Capture virtualization validation build log --- .github/workflows/virtualization-validation.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/virtualization-validation.yml b/.github/workflows/virtualization-validation.yml index 59c99415..6718098b 100644 --- a/.github/workflows/virtualization-validation.yml +++ b/.github/workflows/virtualization-validation.yml @@ -25,7 +25,17 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --no-restore + 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" From ebf0026755976ca08bda400d29ab0562f0c2220e Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:15:22 +1000 Subject: [PATCH 12/61] Fix select virtualization text assertions --- .../Components/SelectExtendedVirtualizationTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs index 700eda08..6df6c29e 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -1,6 +1,5 @@ using AwesomeAssertions; using Bunit; -using MudExtensions.UnitTests.Extensions; namespace MudExtensions.UnitTests.Components { @@ -37,7 +36,7 @@ public void VirtualizedItemCollection_InitializedMultiSelectionStillProducesText .Add(x => x.SelectedValues, selectedValues)); cut.WaitForAssertion(() => - cut.Instance.GetState(x => x.Text).Should().Be("17, 3999")); + cut.Find("input").Attributes["value"]?.Value.Should().Be("17, 3999")); } [Test] From 115971269c759ff7fc29a72989c59d69fc19caa5 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:15:32 +1000 Subject: [PATCH 13/61] Fix strict virtualization text assertion --- .../Components/SelectExtendedVirtualizationStrictTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs index 338c693b..83a959b3 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs @@ -1,6 +1,5 @@ using AwesomeAssertions; using Bunit; -using MudExtensions.UnitTests.Extensions; namespace MudExtensions.UnitTests.Components { @@ -19,7 +18,7 @@ public void StrictMode_ValidOffscreenInitialValue_RemainsRepresentable() .Add(x => x.Value, 3_999)); cut.WaitForAssertion(() => - cut.Instance.GetState(x => x.Text).Should().Be("3999")); + 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); From 88de0055ac44c7621d7c8451f5f6868478ff90d3 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:15:42 +1000 Subject: [PATCH 14/61] Fix single selection virtualization text assertion --- .../SelectExtendedVirtualizationSingleSelectionTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs index 72a9b8df..912d16f2 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs @@ -1,6 +1,5 @@ using AwesomeAssertions; using Bunit; -using MudExtensions.UnitTests.Extensions; namespace MudExtensions.UnitTests.Components { @@ -18,7 +17,7 @@ public void VirtualizedItemCollection_InitializedSingleSelectionUsesOneShadowIte .Add(x => x.Value, 3_999)); cut.WaitForAssertion(() => - cut.Instance.GetState(x => x.Text).Should().Be("3999")); + 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); From 635b8b08ccfe0eefecd8aec8d0f72ce7d3a6c8b0 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:23:32 +1000 Subject: [PATCH 15/61] Fix comparer virtualization test setup order --- .../Components/SelectExtendedVirtualizationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs index 6df6c29e..720b9085 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -54,9 +54,9 @@ public void VirtualizedItemCollection_ShadowListRespectsComparer() .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) - .Add(x => x.SelectedValues, selectedValues)); + .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); From 397f920adfc4e2514cc847c68b64b41b50dbf3f8 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:33:50 +1000 Subject: [PATCH 16/61] Add BenchmarkDotNet benchmark project --- ...eam.MudBlazor.Extensions.Benchmarks.csproj | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj 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 + + + + + + + + + + + + From 516976a6052b5b4d084f53699852444e69513e62 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:34:01 +1000 Subject: [PATCH 17/61] Add bUnit benchmark context --- .../BenchmarkBunitContext.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs 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; + } +} From 9bfb1600a90b8c2d04b519b18f6ad42f349b1072 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:34:12 +1000 Subject: [PATCH 18/61] Add select benchmark host component --- .../SelectBenchmarkHost.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs 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(); + } + } +} From 7b135a78724f9be5f94e4d5e4df36eeb0c5f3ee4 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:34:34 +1000 Subject: [PATCH 19/61] Add select initial render benchmarks --- .../SelectInitialRenderBenchmarks.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs new file mode 100644 index 00000000..90b599aa --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -0,0 +1,36 @@ +using BenchmarkDotNet.Attributes; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "InitialRender")] +public class SelectInitialRenderBenchmarks +{ + 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 int RenderSelect() + { + using var context = BenchmarkBunitContext.Create(); + using var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, Virtualize)); + + return cut.RenderCount; + } +} From 533db546a871512883166f50008138eb9f71ae46 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:34:47 +1000 Subject: [PATCH 20/61] Add select multiplicity benchmarks --- .../SelectMultiplicityBenchmarks.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs new file mode 100644 index 00000000..27e32ff5 --- /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 int RenderVirtualizedSelects() + { + using var context = BenchmarkBunitContext.Create(); + using 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; + } +} From 5596aa9841e90934498cf0e5c89d2b21eed45fb7 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:34:57 +1000 Subject: [PATCH 21/61] Add select selection density benchmarks --- .../SelectSelectionDensityBenchmarks.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs new file mode 100644 index 00000000..fb80dae1 --- /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 int RenderVirtualizedSelect() + { + using var context = BenchmarkBunitContext.Create(); + using var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, true)); + + return cut.RenderCount; + } +} From aa40016ade8c0d0b3e07d105adfe259682222770 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:35:59 +1000 Subject: [PATCH 22/61] Add Blazor virtualization scale probe --- .../ScaleProbe.cs | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs new file mode 100644 index 00000000..981cda0f --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Globalization; +using Bunit; + +namespace MudExtensions.Benchmarks; + +internal static class ScaleProbe +{ + public static int Run(string[] args) + { + var outputPath = GetOption(args, "--output"); + var variant = Environment.GetEnvironmentVariable("BENCHMARK_VARIANT") ?? "current"; + + WarmUp(); + + var results = Scenarios().Select(scenario => Measure(variant, scenario)).ToArray(); + WriteTable(results); + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, ToCsv(results)); + Console.WriteLine($"\nWrote {fullPath}"); + } + + return 0; + } + + private static ProbeResult Measure(string variant, ProbeScenario scenario) + { + var items = Enumerable.Range(1, scenario.ItemCount).Select(static value => (int?)value).ToList(); + var selectedValues = CreateSelectedValues(scenario.ItemCount, scenario.SelectedCount); + + 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(); + using 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 void WarmUp() + { + var items = Enumerable.Range(1, 10).Select(static value => (int?)value).ToList(); + using var context = BenchmarkBunitContext.Create(); + using var _ = 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); +} From 97ef12d4476c069cd938d15097430b59ab76ec3e Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:36:08 +1000 Subject: [PATCH 23/61] Add benchmark runner entry point --- .../Program.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs new file mode 100644 index 00000000..54e4aeda --- /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 ScaleProbe.Run(args[1..]); +} + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +return 0; From b1b896ed9985e596772c7ca5603cb9710b5b4eb9 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:36:50 +1000 Subject: [PATCH 24/61] Fix bUnit benchmark imports --- .../SelectInitialRenderBenchmarks.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs index 90b599aa..63444133 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Attributes; +using Bunit; namespace MudExtensions.Benchmarks; From 8ecf6d4e5e39bc72b91bddcc6bbbbdf39e517c63 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:37:25 +1000 Subject: [PATCH 25/61] Add repeatable baseline and fixed benchmark runner --- benchmarks/run-virtualization-benchmarks.ps1 | 128 +++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 benchmarks/run-virtualization-benchmarks.ps1 diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 new file mode 100644 index 00000000..4f964afd --- /dev/null +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding()] +param( + [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", + [string]$FixedRef = "635b8b08ccfe0eefecd8aec8d0f72ce7d3a6c8b0", + [string]$ResultsDirectory, + [switch]$Quick +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$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 + + Invoke-Checked dotnet @( + "run", "--project", $project, "--configuration", "Release", "--", + "probe", "--output", $probeOutput + ) + + $benchmarkArguments = [System.Collections.Generic.List[string]]::new() + @( + "run", "--project", $project, "--configuration", "Release", "--", + "--filter", "*Select*", + "--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() +} + +try { + @( + "Baseline ref: $BaselineRef", + "Baseline SHA: $baselineSha", + "Fixed ref: $FixedRef", + "Fixed SHA: $fixedSha", + "Started: $([DateTimeOffset]::Now.ToString('O'))", + "Quick: $Quick" + ) | Out-File -FilePath (Join-Path $ResultsDirectory "run-info.txt") -Encoding utf8 + + Invoke-Variant "baseline" $baselineSha + Invoke-Variant "fixed" $fixedSha + + 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 + } +} From 970b0c82ce324f8a75b18889e907669e436baba3 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:37:53 +1000 Subject: [PATCH 26/61] Document virtualization benchmark methodology --- benchmarks/README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 benchmarks/README.md diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..08f093e8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,55 @@ +# 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: + +- initial `MudSelectExtended` render with 10, 100, 1,000 and 4,000 items; +- `Virtualize=false` and `Virtualize=true` initial 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. + +## 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 baseline and fixed SHAs, copies the exact same benchmark harness into each, and runs both variants sequentially on the same machine. It records the resolved SHAs and `dotnet --info` alongside the output. + +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. + +## 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. + +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. From 17e84d681ec9e19644a4a6a85692eb2924ad57c8 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:38:08 +1000 Subject: [PATCH 27/61] Include benchmark project in solution --- CodeBeamMudExtensions.slnx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CodeBeamMudExtensions.slnx b/CodeBeamMudExtensions.slnx index 089d99fc..44298196 100644 --- a/CodeBeamMudExtensions.slnx +++ b/CodeBeamMudExtensions.slnx @@ -1,4 +1,7 @@ + + + From 466a2d4c928c821c60466976010fd5d45649e542 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:40:10 +1000 Subject: [PATCH 28/61] Add direct list render benchmarks --- .../ListExtendedInitialRenderBenchmarks.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs new file mode 100644 index 00000000..679a5433 --- /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 int RenderList() + { + using var context = BenchmarkBunitContext.Create(); + using 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; + } +} From d00ee137b96b74d7f833cbf458f80bf09e66c315 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:40:30 +1000 Subject: [PATCH 29/61] Document direct list benchmarks --- benchmarks/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 08f093e8..8a584c8c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -10,8 +10,9 @@ The project deliberately lives outside the unit-test project. NUnit + bUnit test 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` initial renders; +- `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. @@ -52,4 +53,6 @@ Results are written below `BenchmarkDotNet.Artifacts/virtualized-list-selection- 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. From f9e83afa79629b0fc6df6641bc55eb26add7e44c Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:41:25 +1000 Subject: [PATCH 30/61] Run all virtualization benchmark classes --- benchmarks/run-virtualization-benchmarks.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index 4f964afd..170f7bcc 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -81,7 +81,7 @@ function Invoke-Variant([string]$name, [string]$sha) { $benchmarkArguments = [System.Collections.Generic.List[string]]::new() @( "run", "--project", $project, "--configuration", "Release", "--", - "--filter", "*Select*", + "--filter", "*", "--artifacts", $bdnArtifacts, "--exporters", "GitHub", "CSV", "JSON", "--allStats", From 07067f2bb64dbbccc9c0669a00222897b806d38c Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:42:08 +1000 Subject: [PATCH 31/61] Support probe-only benchmark runs --- benchmarks/run-virtualization-benchmarks.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index 170f7bcc..e868ae08 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -3,7 +3,8 @@ param( [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", [string]$FixedRef = "635b8b08ccfe0eefecd8aec8d0f72ce7d3a6c8b0", [string]$ResultsDirectory, - [switch]$Quick + [switch]$Quick, + [switch]$ProbeOnly ) $ErrorActionPreference = "Stop" @@ -78,6 +79,10 @@ function Invoke-Variant([string]$name, [string]$sha) { "probe", "--output", $probeOutput ) + if ($ProbeOnly) { + return + } + $benchmarkArguments = [System.Collections.Generic.List[string]]::new() @( "run", "--project", $project, "--configuration", "Release", "--", @@ -103,7 +108,8 @@ try { "Fixed ref: $FixedRef", "Fixed SHA: $fixedSha", "Started: $([DateTimeOffset]::Now.ToString('O'))", - "Quick: $Quick" + "Quick: $Quick", + "Probe only: $ProbeOnly" ) | Out-File -FilePath (Join-Path $ResultsDirectory "run-info.txt") -Encoding utf8 Invoke-Variant "baseline" $baselineSha From 01f02a4618f034ef4bdc461d9bfd8e8770de94c3 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:42:27 +1000 Subject: [PATCH 32/61] Add temporary virtualization benchmark probe workflow --- .../virtualization-benchmark-probe.yml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/virtualization-benchmark-probe.yml diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml new file mode 100644 index 00000000..6a45c638 --- /dev/null +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -0,0 +1,32 @@ +name: Virtualization benchmark probe + +on: + push: + branches: + - benchmarks/virtualized-list-selection-state + workflow_dispatch: + +jobs: + probe: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run baseline and fixed probes + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -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 From bbcc1766da8d17a0c7611cf3a8e0dd252742d9e9 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:45:37 +1000 Subject: [PATCH 33/61] Dispose benchmark contexts asynchronously --- .../SelectInitialRenderBenchmarks.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs index 63444133..65154a7f 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -24,10 +24,10 @@ public void Setup() } [Benchmark] - public int RenderSelect() + public async Task RenderSelect() { - using var context = BenchmarkBunitContext.Create(); - using var cut = context.Render(parameters => parameters + 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)); From 53c8654ec12d63e73572bab0a5034fd91a52c01b Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:45:47 +1000 Subject: [PATCH 34/61] Dispose select multiplicity contexts asynchronously --- .../SelectMultiplicityBenchmarks.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs index 27e32ff5..cc9d8e42 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs @@ -22,10 +22,10 @@ public void Setup() } [Benchmark] - public int RenderVirtualizedSelects() + public async Task RenderVirtualizedSelects() { - using var context = BenchmarkBunitContext.Create(); - using var cut = context.Render(parameters => parameters + 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) From a4a48c063a3c292b974091c910246bdbd855a36a Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:46:01 +1000 Subject: [PATCH 35/61] Dispose selection density contexts asynchronously --- .../SelectSelectionDensityBenchmarks.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs index fb80dae1..68d9f219 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs @@ -25,10 +25,10 @@ public void Setup() } [Benchmark] - public int RenderVirtualizedSelect() + public async Task RenderVirtualizedSelect() { - using var context = BenchmarkBunitContext.Create(); - using var cut = context.Render(parameters => parameters + 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)); From 8097cffa17ff798f221655fa40662b69fab93cfc Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:46:12 +1000 Subject: [PATCH 36/61] Dispose list benchmark contexts asynchronously --- .../ListExtendedInitialRenderBenchmarks.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs index 679a5433..91558b34 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs @@ -24,10 +24,10 @@ public void Setup() } [Benchmark] - public int RenderList() + public async Task RenderList() { - using var context = BenchmarkBunitContext.Create(); - using var cut = context.Render>(parameters => parameters + 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) From 4db588824c582dd95255a391775803a4b1206823 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:46:35 +1000 Subject: [PATCH 37/61] Dispose probe contexts asynchronously --- .../ScaleProbe.cs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs index 981cda0f..91e312bd 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs @@ -6,33 +6,38 @@ namespace MudExtensions.Benchmarks; internal static class ScaleProbe { - public static int Run(string[] args) + public static async Task RunAsync(string[] args) { var outputPath = GetOption(args, "--output"); var variant = Environment.GetEnvironmentVariable("BENCHMARK_VARIANT") ?? "current"; - WarmUp(); + await WarmUpAsync(); + + var results = new List(); + foreach (var scenario in Scenarios()) + { + results.Add(await MeasureAsync(variant, scenario)); + } - var results = Scenarios().Select(scenario => Measure(variant, scenario)).ToArray(); WriteTable(results); if (!string.IsNullOrWhiteSpace(outputPath)) { var fullPath = Path.GetFullPath(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - File.WriteAllText(fullPath, ToCsv(results)); + await File.WriteAllTextAsync(fullPath, ToCsv(results)); Console.WriteLine($"\nWrote {fullPath}"); } return 0; } - private static ProbeResult Measure(string variant, ProbeScenario scenario) + 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); - using var context = BenchmarkBunitContext.Create(); + await using var context = BenchmarkBunitContext.Create(); GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); GC.WaitForPendingFinalizers(); @@ -40,7 +45,7 @@ private static ProbeResult Measure(string variant, ProbeScenario scenario) var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); var stopwatch = Stopwatch.StartNew(); - using var cut = context.Render(parameters => parameters + var cut = context.Render(parameters => parameters .Add(x => x.Items, items) .Add(x => x.SelectedValues, selectedValues) .Add(x => x.SelectCount, scenario.SelectCount) @@ -108,11 +113,11 @@ private static IEnumerable Scenarios() } } - private static void WarmUp() + private static async Task WarmUpAsync() { var items = Enumerable.Range(1, 10).Select(static value => (int?)value).ToList(); - using var context = BenchmarkBunitContext.Create(); - using var _ = context.Render(parameters => parameters + 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)); From bbc120d78ccba4d9c186f9e8b25419f403205c46 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Tue, 15 Sep 2026 23:46:43 +1000 Subject: [PATCH 38/61] Await probe lifecycle --- benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs index 54e4aeda..687cc98d 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs @@ -3,7 +3,7 @@ if (args.Length > 0 && string.Equals(args[0], "probe", StringComparison.OrdinalIgnoreCase)) { - return ScaleProbe.Run(args[1..]); + return await ScaleProbe.RunAsync(args[1..]); } BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); From 5c4ca1c05387d7f686cc0b982f98aa24d24887ce Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:07:39 +1000 Subject: [PATCH 39/61] Preserve non-virtualized select item registry --- .../SelectExtended/MudSelectExtended.ShadowItems.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs index 0cfd708a..078ee741 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.ShadowItems.cs @@ -6,15 +6,15 @@ namespace MudExtensions public partial class MudSelectExtended { /// - /// Returns only selected values that are present in the current item collection. - /// The hidden list exists to materialize selected item components for presentation; - /// it must not materialize the entire collection when the visible list is virtualized. + /// 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) + if (ItemCollection == null || !Virtualize) { - return null; + return ItemCollection; } var selectedValues = SelectedValues?.ToHashSet(_comparer) ?? new HashSet(_comparer); From 76b806ec54cdb43c9b5171bbbd10d4759014583f Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:07:53 +1000 Subject: [PATCH 40/61] Cover non-virtualized select item registry --- .../SelectExtendedVirtualizationScaleTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs index dcd84b69..1d43e2ac 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs @@ -24,5 +24,20 @@ public void ShadowItemCount_IsBoundedBySelection_NotCollectionSize(int itemCount 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); + } } } From d2161e6bced93b7010cc5473e6d2f14de17dcd75 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:08:56 +1000 Subject: [PATCH 41/61] Benchmark final non-virtualized compatibility fix --- benchmarks/run-virtualization-benchmarks.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index e868ae08..5d678588 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", - [string]$FixedRef = "635b8b08ccfe0eefecd8aec8d0f72ce7d3a6c8b0", + [string]$FixedRef = "76b806ec54cdb43c9b5171bbbd10d4759014583f", [string]$ResultsDirectory, [switch]$Quick, [switch]$ProbeOnly From 17249c72db80c79bd8792ad9218adb5cb45a3473 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:12:57 +1000 Subject: [PATCH 42/61] Cover virtualized item-content presentation --- ...endedVirtualizationSingleSelectionTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs index 912d16f2..ab6a51c8 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs @@ -1,5 +1,7 @@ using AwesomeAssertions; using Bunit; +using Microsoft.AspNetCore.Components; +using MudBlazor; namespace MudExtensions.UnitTests.Components { @@ -22,5 +24,26 @@ public void VirtualizedItemCollection_InitializedSingleSelectionUsesOneShadowIte 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); + } } } From 088fc6a8673dd8617458dbf20959e580cc814f12 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:13:18 +1000 Subject: [PATCH 43/61] Cover virtualized selection changes --- .../SelectExtendedVirtualizationTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs index 720b9085..1884d078 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -39,6 +39,30 @@ public void VirtualizedItemCollection_InitializedMultiSelectionStillProducesText cut.Find("input").Attributes["value"]?.Value.Should().Be("17, 3999")); } + [Test] + public async Task 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 })); + + await cut.SetParametersAndRenderAsync(parameters => parameters + .Add(x => x.SelectedValues, new int?[] { 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); + shadowList.TextContent.Should().Contain("3999"); + shadowList.TextContent.Should().NotContain("17"); + }); + } + [Test] public void VirtualizedItemCollection_ShadowListRespectsComparer() { From 0f5460a9a2ada4c5100d81fc0398668d5049b63b Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:14:39 +1000 Subject: [PATCH 44/61] Support focused benchmark runs --- benchmarks/run-virtualization-benchmarks.ps1 | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index 5d678588..eaecc7bb 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -3,13 +3,19 @@ param( [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", [string]$FixedRef = "76b806ec54cdb43c9b5171bbbd10d4759014583f", [string]$ResultsDirectory, + [string]$BenchmarkFilter = "*", [switch]$Quick, - [switch]$ProbeOnly + [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." @@ -74,10 +80,12 @@ function Invoke-Variant([string]$name, [string]$sha) { (& 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 - Invoke-Checked dotnet @( - "run", "--project", $project, "--configuration", "Release", "--", - "probe", "--output", $probeOutput - ) + if (-not $SkipProbe) { + Invoke-Checked dotnet @( + "run", "--project", $project, "--configuration", "Release", "--", + "probe", "--output", $probeOutput + ) + } if ($ProbeOnly) { return @@ -86,7 +94,7 @@ function Invoke-Variant([string]$name, [string]$sha) { $benchmarkArguments = [System.Collections.Generic.List[string]]::new() @( "run", "--project", $project, "--configuration", "Release", "--", - "--filter", "*", + "--filter", $BenchmarkFilter, "--artifacts", $bdnArtifacts, "--exporters", "GitHub", "CSV", "JSON", "--allStats", @@ -108,8 +116,10 @@ try { "Fixed ref: $FixedRef", "Fixed SHA: $fixedSha", "Started: $([DateTimeOffset]::Now.ToString('O'))", + "Benchmark filter: $BenchmarkFilter", "Quick: $Quick", - "Probe only: $ProbeOnly" + "Probe only: $ProbeOnly", + "Skip probe: $SkipProbe" ) | Out-File -FilePath (Join-Path $ResultsDirectory "run-info.txt") -Encoding utf8 Invoke-Variant "baseline" $baselineSha From 2ae16de6049b5f9de34358b339486075acf0699c Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:14:53 +1000 Subject: [PATCH 45/61] Run focused BenchmarkDotNet smoke comparison --- .../virtualization-benchmark-probe.yml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml index 6a45c638..1e0a2adc 100644 --- a/.github/workflows/virtualization-benchmark-probe.yml +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -30,3 +30,27 @@ jobs: with: name: virtualization-benchmark-probe path: benchmark-results + + quick-benchmark: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run focused BenchmarkDotNet comparison + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -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 From 4f25e5a35f67c94fa7e421bc6041665456a0f835 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:18:48 +1000 Subject: [PATCH 46/61] Use bUnit v2 rerender API --- .../Components/SelectExtendedVirtualizationTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs index 1884d078..56787303 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -40,7 +40,7 @@ public void VirtualizedItemCollection_InitializedMultiSelectionStillProducesText } [Test] - public async Task VirtualizedItemCollection_ChangedSelectionReplacesShadowItems() + public void VirtualizedItemCollection_ChangedSelectionReplacesShadowItems() { var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); @@ -50,7 +50,10 @@ public async Task VirtualizedItemCollection_ChangedSelectionReplacesShadowItems( .Add(x => x.MultiSelection, true) .Add(x => x.SelectedValues, new int?[] { 17 })); - await cut.SetParametersAndRenderAsync(parameters => parameters + 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 })); cut.WaitForAssertion(() => From b825a441c5ece94ac51e8a0241086736844646b0 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Wed, 16 Sep 2026 00:21:40 +1000 Subject: [PATCH 47/61] Benchmark settled select rendering --- .../SelectInitialRenderBenchmarks.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs index 65154a7f..a3629960 100644 --- a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -9,6 +9,7 @@ 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; } @@ -21,6 +22,7 @@ public void Setup() { _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); _selectedValues = [1, ItemCount]; + _expectedText = $"1, {ItemCount}"; } [Benchmark] @@ -32,6 +34,15 @@ public async Task RenderSelect() .Add(x => x.SelectedValues, _selectedValues) .Add(x => x.Virtualize, Virtualize)); - return cut.RenderCount; + 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; } } From 1fbc3ce6ae073a9ab7b2062b73139d6d80f5bd47 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Thu, 17 Sep 2026 02:40:51 +1000 Subject: [PATCH 48/61] test: isolate dynamic virtualized selection state from shadow rendering and text --- .../Components/SelectExtendedVirtualizationTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs index 56787303..1c0337c7 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -50,20 +50,30 @@ public void VirtualizedItemCollection_ChangedSelectionReplacesShadowItems() .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(() => { - 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); shadowList.TextContent.Should().Contain("3999"); shadowList.TextContent.Should().NotContain("17"); }); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); } [Test] From deac1870d9d3bc72eb4e72db409965ca9f402932 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Fri, 18 Sep 2026 10:05:04 +1000 Subject: [PATCH 49/61] fix: synchronize virtualized multiselect text after parameter selection changes --- ...SelectExtended.VirtualizedSelectionText.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.VirtualizedSelectionText.cs 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); + } + } + } +} From c74d035b030013b819304ee8bff015843a582430 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sat, 26 Sep 2026 23:31:50 +1000 Subject: [PATCH 50/61] Fix virtualized shadow list refresh on selection changes --- .../Components/SelectExtended/MudSelectExtended.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 1f480daf..9106bfb0 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor @@ -163,7 +163,7 @@
- Date: Sat, 26 Sep 2026 23:31:52 +1000 Subject: [PATCH 51/61] Unsubscribe disposed select items from selection updates --- .../SelectExtended/MudSelectItemExtended.razor.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs index f42fc13c..a74395f3 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs @@ -198,7 +198,12 @@ public void Dispose() { try { - MudSelectExtended?.Remove(this); + if (MudSelectExtended is { } select) + { + select.SelectionChangedFromOutside -= OnUpdateSelectionStateFromOutside; + select.Remove(this); + } + ((MudSelectExtended?)_shadowParent)?.UnregisterShadowItem(this); } catch (Exception) { } From c05db2b1c96eac96405e0551905e5ee795ec1720 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sat, 26 Sep 2026 23:35:01 +1000 Subject: [PATCH 52/61] Limit shadow list re-keying to virtualized selects --- .../Components/SelectExtended/MudSelectExtended.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 9106bfb0..482cc2ff 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor @@ -163,7 +163,7 @@
- Date: Sun, 27 Sep 2026 00:45:22 +1000 Subject: [PATCH 53/61] Document benchmark reproducibility requirement --- benchmarks/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/benchmarks/README.md b/benchmarks/README.md index 8a584c8c..8150940e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -56,3 +56,7 @@ The key expected scaling characteristic is that a virtualized select should not 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. + +## 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. From 549199d27df202863b7bea8fa9e9fd55b1499482 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 00:46:29 +1000 Subject: [PATCH 54/61] Default benchmark fixed ref to current branch head --- benchmarks/run-virtualization-benchmarks.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index eaecc7bb..92940c65 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", - [string]$FixedRef = "76b806ec54cdb43c9b5171bbbd10d4759014583f", + [string]$FixedRef = "HEAD", [string]$ResultsDirectory, [string]$BenchmarkFilter = "*", [switch]$Quick, From 46692bb66abbf1d18aec091cfda322c27ed64d25 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 00:47:22 +1000 Subject: [PATCH 55/61] Document opt-in benchmark usage for CI and workstations --- benchmarks/README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/benchmarks/README.md b/benchmarks/README.md index 8150940e..3c4233d4 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -49,6 +49,76 @@ For a faster smoke run: 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. From 505ecc7afd3bc70efaf9d3887000045cfdccb1cc Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 00:47:54 +1000 Subject: [PATCH 56/61] Benchmark against current upstream dev baseline --- benchmarks/run-virtualization-benchmarks.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 index 92940c65..3173fe23 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$BaselineRef = "d0ae5981e80c4a493b29ccff7a7cb80b22fce0d5", + [string]$BaselineRef = "7b5faf7ebb7666558d13c447313e9b09c92a110d", [string]$FixedRef = "HEAD", [string]$ResultsDirectory, [string]$BenchmarkFilter = "*", From 4b026f42862e2e686af21783019f3478b3f32e23 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 00:48:38 +1000 Subject: [PATCH 57/61] Run benchmark validation for harness PR changes --- .github/workflows/virtualization-benchmark-probe.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml index 1e0a2adc..79d4dc0f 100644 --- a/.github/workflows/virtualization-benchmark-probe.yml +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -4,6 +4,12 @@ on: push: branches: - benchmarks/virtualized-list-selection-state + pull_request: + branches: + - dev + paths: + - 'benchmarks/**' + - '.github/workflows/virtualization-benchmark-probe.yml' workflow_dispatch: jobs: @@ -22,7 +28,7 @@ jobs: - name: Run baseline and fixed probes shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" - name: Upload probe results if: always() @@ -46,7 +52,7 @@ jobs: - name: Run focused BenchmarkDotNet comparison shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" - name: Upload BenchmarkDotNet results if: always() From 464019a6adb9df76f461c8591b782621523a57ad Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 00:48:49 +1000 Subject: [PATCH 58/61] Document exact upstream benchmark baseline --- benchmarks/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmarks/README.md b/benchmarks/README.md index 3c4233d4..28297e75 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -127,6 +127,15 @@ The direct-list cases are intentionally included as a control: the `MudListExten 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. + +The runner records both 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. From 2b29bcf7bcd1a8c42bebfc61a6cd447c3063e29e Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 01:18:41 +1000 Subject: [PATCH 59/61] Add three-stage benchmark comparison evidence --- .../virtualization-benchmark-probe.yml | 8 +- benchmarks/README.md | 33 ++++- benchmarks/run-virtualization-benchmarks.ps1 | 134 ++++++++++++++++++ 3 files changed, 169 insertions(+), 6 deletions(-) diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml index 79d4dc0f..8c81a59f 100644 --- a/.github/workflows/virtualization-benchmark-probe.yml +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -26,9 +26,9 @@ jobs: with: dotnet-version: 10.0.x - - name: Run baseline and fixed probes + - name: Run three-stage structural probe shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -Stage1Ref "76b806ec54cdb43c9b5171bbbd10d4759014583f" -FixedRef HEAD -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" - name: Upload probe results if: always() @@ -50,9 +50,9 @@ jobs: with: dotnet-version: 10.0.x - - name: Run focused BenchmarkDotNet comparison + - name: Run authoritative upstream-to-HEAD BenchmarkDotNet comparison shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef HEAD -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" - name: Upload BenchmarkDotNet results if: always() diff --git a/benchmarks/README.md b/benchmarks/README.md index 28297e75..391bdcb4 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -29,6 +29,29 @@ A separate `probe` mode records Blazor-specific shape metrics which BenchmarkDot 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: @@ -37,7 +60,7 @@ From the benchmark branch: ./benchmarks/run-virtualization-benchmarks.ps1 ``` -The script creates temporary detached Git worktrees for the baseline and fixed SHAs, copies the exact same benchmark harness into each, and runs both variants sequentially on the same machine. It records the resolved SHAs and `dotnet --info` alongside the output. +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: @@ -135,7 +158,13 @@ For the final PR1/PR2 comparison in this branch, the baseline is pinned to upstr 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. -The runner records both 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. +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 index 3173fe23..ad16ecdd 100644 --- a/benchmarks/run-virtualization-benchmarks.ps1 +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( [string]$BaselineRef = "7b5faf7ebb7666558d13c447313e9b09c92a110d", + [string]$Stage1Ref, [string]$FixedRef = "HEAD", [string]$ResultsDirectory, [string]$BenchmarkFilter = "*", @@ -46,6 +47,7 @@ function Invoke-Checked([string]$command, [string[]]$arguments) { } $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'))" @@ -109,10 +111,138 @@ function Invoke-Variant([string]$name, [string]$sha) { 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'))", @@ -123,7 +253,11 @@ try { ) | 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 } From 90ffe7ae140403cd854a73700c848fe7d051e0f1 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 01:27:59 +1000 Subject: [PATCH 60/61] Benchmark exact candidate SHA in CI --- .github/workflows/virtualization-benchmark-probe.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml index 8c81a59f..e6448480 100644 --- a/.github/workflows/virtualization-benchmark-probe.yml +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -20,6 +20,8 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -28,7 +30,7 @@ jobs: - name: Run three-stage structural probe shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -Stage1Ref "76b806ec54cdb43c9b5171bbbd10d4759014583f" -FixedRef HEAD -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + 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() @@ -52,7 +54,7 @@ jobs: - name: Run authoritative upstream-to-HEAD BenchmarkDotNet comparison shell: pwsh - run: ./benchmarks/run-virtualization-benchmarks.ps1 -SkipProbe -Quick -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef HEAD -BenchmarkFilter "*SelectInitialRenderBenchmarks*" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + 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() From ff5a6ce411a7000f10a1f7131d097018df505d03 Mon Sep 17 00:00:00 2001 From: Aaron Glover Date: Sun, 27 Sep 2026 01:28:18 +1000 Subject: [PATCH 61/61] Fix exact candidate checkout for benchmark CI --- .github/workflows/virtualization-benchmark-probe.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml index e6448480..165129a8 100644 --- a/.github/workflows/virtualization-benchmark-probe.yml +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -21,7 +21,6 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -46,13 +45,14 @@ jobs: - 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-HEAD BenchmarkDotNet comparison + - 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"