Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8c53be1
Fix virtualized list item selection state
aarondglover Sep 15, 2026
71ab450
Limit select shadow items to selected values
aarondglover Sep 15, 2026
4fbee31
Keep select shadow list bounded to selection
aarondglover Sep 15, 2026
bf2cc9e
Restore select error identifier binding
aarondglover Sep 15, 2026
fad9966
Add virtualized list selection regression tests
aarondglover Sep 15, 2026
39857e4
Add select virtualization regression tests
aarondglover Sep 15, 2026
98f325e
Protect shadow list scaling behavior
aarondglover Sep 15, 2026
89d94c6
Add strict virtualization regression coverage
aarondglover Sep 15, 2026
3ee1d3e
Cover initialized single selection virtualization
aarondglover Sep 15, 2026
9651a3b
Add temporary virtualization validation workflow
aarondglover Sep 15, 2026
3209fff
Capture virtualization validation build log
aarondglover Sep 15, 2026
ebf0026
Fix select virtualization text assertions
aarondglover Sep 15, 2026
1159712
Fix strict virtualization text assertion
aarondglover Sep 15, 2026
88de005
Fix single selection virtualization text assertion
aarondglover Sep 15, 2026
635b8b0
Fix comparer virtualization test setup order
aarondglover Sep 15, 2026
5c4ca1c
Preserve non-virtualized select item registry
aarondglover Sep 15, 2026
76b806e
Cover non-virtualized select item registry
aarondglover Sep 15, 2026
17249c7
Cover virtualized item-content presentation
aarondglover Sep 15, 2026
088fc6a
Cover virtualized selection changes
aarondglover Sep 15, 2026
4f25e5a
Use bUnit v2 rerender API
aarondglover Sep 15, 2026
1fbc3ce
test: isolate dynamic virtualized selection state from shadow renderi…
aarondglover Sep 16, 2026
deac187
fix: synchronize virtualized multiselect text after parameter selecti…
aarondglover Sep 18, 2026
c74d035
Fix virtualized shadow list refresh on selection changes
aarondglover Sep 26, 2026
2ccf246
Unsubscribe disposed select items from selection updates
aarondglover Sep 26, 2026
c05db2b
Limit shadow list re-keying to virtualized selects
aarondglover Sep 26, 2026
50785c3
Merge upstream dev into virtualization fix
aarondglover Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/virtualization-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Virtualization validation

on:
push:
branches:
- fix/virtualized-list-selection-state
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Restore
run: dotnet restore

- name: Build
shell: bash
run: |
set -o pipefail
dotnet build --no-restore 2>&1 | tee build.log

- name: Upload build log
if: always()
uses: actions/upload-artifact@v4
with:
name: virtualization-build-log
path: build.log

- name: Test
run: dotnet test --configuration Release -f net10.0 --collect:"XPlat Code Coverage"
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;

namespace MudExtensions
{
public partial class MudListItemExtended<T>
{
/// <summary>
/// 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.
/// </summary>
protected override void OnParametersSet()
{
base.OnParametersSet();

if (MudListExtended == null)
{
return;
}

var comparer = MudListExtended.Comparer ?? EqualityComparer<T?>.Default;

_selected = MudListExtended.MultiSelection
? MudListExtended.SelectedValues?.Contains(Value, comparer) == true
: comparer.Equals(MudListExtended.SelectedValue, Value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Linq;

namespace MudExtensions
{
public partial class MudSelectExtended<T>
{
/// <summary>
/// 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.
/// </summary>
protected ICollection<T?>? GetShadowItemCollection()
{
if (ItemCollection == null || !Virtualize)
{
return ItemCollection;
}

var selectedValues = SelectedValues?.ToHashSet(_comparer) ?? new HashSet<T?>(_comparer);
return [.. ItemCollection.Where(selectedValues.Contains)];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace MudExtensions
{
public partial class MudSelectExtended<T>
{
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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@namespace MudExtensions
@namespace MudExtensions
@typeparam T
@inherits MudBaseInputExtended<T>

Expand Down Expand Up @@ -159,12 +159,12 @@
</MudInputControl>
</div>

@*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.*@

<div style="display: none">
<CascadingValue Value="@this" IsFixed="true">
<MudListExtended T="T" @bind-SelectedValue:set="@SetValueCoreAsync" @bind-SelectedValue:get="@ReadValue" @bind-SelectedValues="@SelectedValues" @bind-SelectedItem="@SelectedListItem" @bind-SelectedItems="@SelectedListItems"
Clickable="true" Color="@Color" Dense="@Dense" ItemCollection="@ItemCollection" Padding="@EnablePopoverPadding" EnableSelectedItemStyle="@EnableSelectedItemStyle"
<MudListExtended @key="Virtualize ? SelectedValues : null" T="T" @bind-SelectedValue:set="@SetValueCoreAsync" @bind-SelectedValue:get="@ReadValue" @bind-SelectedValues="@SelectedValues" @bind-SelectedItem="@SelectedListItem" @bind-SelectedItems="@SelectedListItems"
Clickable="true" Color="@Color" Dense="@Dense" ItemCollection="@GetShadowItemCollection()" Padding="@EnablePopoverPadding" EnableSelectedItemStyle="@EnableSelectedItemStyle"
MultiSelection="@MultiSelection" MultiSelectionComponent="@MultiSelectionComponent" MultiSelectionAlign="@MultiSelectionAlign" SelectAll="@SelectAll" SelectAllPosition="@SelectAllPosition" SelectAllText="@SelectAllText"
CheckedIcon="@CheckedIcon" UncheckedIcon="@UncheckedIcon" IndeterminateIcon="@IndeterminateIcon" SelectValueOnTab="@SelectValueOnTab" Comparer="@Comparer" AddNullItem="@AddNullItem" AddedNullItemText="@AddedNullItemText"
ItemTemplate="@ItemTemplate" ItemSelectedTemplate="@ItemSelectedTemplate" ItemDisabledTemplate="@ItemDisabledTemplate" SearchBox="@SearchBox" SearchBoxAutoFocus="@SearchBoxAutoFocus" SearchFunc="@SearchFunc" SearchBoxPlaceholder="@SearchBoxPlaceholder"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,12 @@ public void Dispose()
{
try
{
MudSelectExtended?.Remove(this);
if (MudSelectExtended is { } select)
{
select.SelectionChangedFromOutside -= OnUpdateSelectionStateFromOutside;
select.Remove(this);
}

((MudSelectExtended<T?>?)_shadowParent)?.UnregisterShadowItem(this);
}
catch (Exception) { }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MudListItemExtended<int?>>();
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<MudListItemExtended<int?>>();
ReferenceEquals(initialItem.Instance, reusedItem.Instance).Should().BeTrue();
reusedItem.Instance.IsSelected.Should().BeTrue();
}

[Test]
public void MaterializedListItem_UsesConfiguredComparerForSelection()
{
var cut = Context.Render<MudListExtended<int?>>(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<MudListItemExtended<int?>>().Instance.IsSelected.Should().BeTrue();
}

private IRenderedComponent<MudListExtended<int?>> RenderList(IEnumerable<int?> selectedValues, int? value)
{
return Context.Render<MudListExtended<int?>>(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<MudListItemExtended<int?>>(0);
builder.AddAttribute(1, nameof(MudListItemExtended<int?>.Value), value);
builder.CloseComponent();
};

private sealed class AbsoluteValueComparer : IEqualityComparer<int?>
{
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();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using AwesomeAssertions;
using Bunit;

namespace MudExtensions.UnitTests.Components
{
[TestFixture]
public class SelectExtendedVirtualizationScaleTests : BunitTest
{
[TestCase(10)]
[TestCase(100)]
[TestCase(1_000)]
[TestCase(4_000)]
public void ShadowItemCount_IsBoundedBySelection_NotCollectionSize(int itemCount)
{
var items = Enumerable.Range(1, itemCount).Select(value => (int?)value).ToList();
var selectedValues = new int?[] { 1, itemCount };

var cut = Context.Render<MudSelectExtended<int?>>(parameters => parameters
.Add(x => x.ItemCollection, items)
.Add(x => x.Virtualize, true)
.Add(x => x.MultiSelection, true)
.Add(x => x.SelectedValues, selectedValues));

var shadowList = cut.Find("div[style='display: none']");
shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(2);
}

[Test]
public void NonVirtualizedItemCollection_PreservesFullRegisteredItemSet()
{
var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList();
var selectedValues = new int?[] { 1, 100 };

var cut = Context.Render<MudSelectExtended<int?>>(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using AwesomeAssertions;
using Bunit;
using Microsoft.AspNetCore.Components;
using MudBlazor;

namespace MudExtensions.UnitTests.Components
{
[TestFixture]
public class SelectExtendedVirtualizationSingleSelectionTests : BunitTest
{
[Test]
public void VirtualizedItemCollection_InitializedSingleSelectionUsesOneShadowItem()
{
var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList();

var cut = Context.Render<MudSelectExtended<int?>>(parameters => parameters
.Add(x => x.ItemCollection, items)
.Add(x => x.Virtualize, true)
.Add(x => x.Value, 3_999));

cut.WaitForAssertion(() =>
cut.Find("input").Attributes["value"]?.Value.Should().Be("3999"));

var shadowList = cut.Find("div[style='display: none']");
shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1);
}

[Test]
public void VirtualizedItemCollection_OffscreenValueSupportsItemContentPresenter()
{
var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList();
RenderFragment<MudListItemExtended<int?>> template = item => builder =>
builder.AddContent(0, $"Selected item {item.Value}");

var cut = Context.Render<MudSelectExtended<int?>>(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using AwesomeAssertions;
using Bunit;

namespace MudExtensions.UnitTests.Components
{
[TestFixture]
public class SelectExtendedVirtualizationStrictTests : BunitTest
{
[Test]
public void StrictMode_ValidOffscreenInitialValue_RemainsRepresentable()
{
var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList();

var cut = Context.Render<MudSelectExtended<int?>>(parameters => parameters
.Add(x => x.ItemCollection, items)
.Add(x => x.Virtualize, true)
.Add(x => x.Strict, true)
.Add(x => x.Value, 3_999));

cut.WaitForAssertion(() =>
cut.Find("input").Attributes["value"]?.Value.Should().Be("3999"));

var shadowList = cut.Find("div[style='display: none']");
shadowList.QuerySelectorAll("div.mud-list-item-extended").Count().Should().Be(1);
}

[Test]
public void StrictMode_ValueOutsideItemCollection_IsNotAddedToShadowList()
{
var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList();

var cut = Context.Render<MudSelectExtended<int?>>(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();
}
}
}
Loading
Loading