From 765f167f5e5be084c0d294c60ab137d21de34d06 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 24 Aug 2026 18:54:19 +0200 Subject: [PATCH 1/7] MudTable: opt-in AutoReloadOnCollectionChanged / AutoReloadOnItemPropertyChanged Parity with MudDataGrid (#11822) for MudTable: when Items implements INotifyCollectionChanged, or items implement INotifyPropertyChanged, the table re-renders on change without reassigning Items or calling StateHasChanged. Both parameters default to false, so existing behaviour is unchanged. Subscriptions follow Items reassignment and parameter changes, are released on Dispose, and a burst of notifications is coalesced into one render on the next dispatcher turn. Notifications may arrive on any thread. No effect with ServerData. Co-Authored-By: Claude Fable 5 --- .../Table/TableAutoReloadTest.razor | 64 ++++++ .../Components/TableAutoReloadTests.cs | 170 +++++++++++++++ .../Components/Table/MudTable.razor.cs | 195 +++++++++++++++++- 3 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor create mode 100644 src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor new file mode 100644 index 000000000000..0dc3c452399d --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor @@ -0,0 +1,64 @@ +@using System.Collections.Specialized +@using System.ComponentModel + + + + Name + + + @context.Name + + + +@code { + public static string __description__ = "AutoReloadOnCollectionChanged / AutoReloadOnItemPropertyChanged re-render the table from INotifyCollectionChanged / INotifyPropertyChanged without reassigning Items."; + + [Parameter] public bool AutoReloadOnCollectionChanged { get; set; } + [Parameter] public bool AutoReloadOnItemPropertyChanged { get; set; } + [Parameter] public TrackedCollection Source { get; set; } = new() { new Item("a"), new Item("b"), new Item("c") }; + + /// An item that counts its PropertyChanged subscribers so tests can prove unsubscription. + public sealed class Item(string name) : INotifyPropertyChanged + { + private string _name = name; + private PropertyChangedEventHandler? _propertyChanged; + + public int SubscriberCount { get; private set; } + + public string Name + { + get => _name; + set + { + _name = value; + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged + { + add { _propertyChanged += value; SubscriberCount++; } + remove { _propertyChanged -= value; SubscriberCount--; } + } + } + + /// An ObservableCollection that counts its CollectionChanged subscribers. + public sealed class TrackedCollection : System.Collections.ObjectModel.ObservableCollection, INotifyCollectionChanged + { + private NotifyCollectionChangedEventHandler? _handler; + + public int SubscriberCount { get; private set; } + + event NotifyCollectionChangedEventHandler? INotifyCollectionChanged.CollectionChanged + { + add { _handler += value; SubscriberCount++; } + remove { _handler -= value; SubscriberCount--; } + } + + protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) + { + base.OnCollectionChanged(e); + _handler?.Invoke(this, e); + } + } +} diff --git a/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs new file mode 100644 index 000000000000..e670751fff48 --- /dev/null +++ b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs @@ -0,0 +1,170 @@ +using AwesomeAssertions; +using Bunit; +using MudBlazor.UnitTests.TestComponents.Table; +using NUnit.Framework; + +namespace MudBlazor.UnitTests.Components +{ + /// + /// and : + /// opt-in re-rendering from INotifyCollectionChanged / INotifyPropertyChanged, parity with MudDataGrid (#11822). + /// + [TestFixture] + public class TableAutoReloadTests : BunitTest + { + private static int RowCount(IRenderedComponent comp) => comp.FindAll("tbody tr.mud-table-row").Count; + + [Test] + public void Default_DoesNotSubscribe_AndDoesNotRerenderOnAdd() + { + var comp = Context.Render(); + var source = comp.Instance.Source; + RowCount(comp).Should().Be(3); + source.SubscriberCount.Should().Be(0, "both flags default to false → upstream behaviour is unchanged"); + source[0].SubscriberCount.Should().Be(0); + + var renders = comp.RenderCount; + source.Add(new TableAutoReloadTest.Item("d")); + Thread.Sleep(50); + comp.RenderCount.Should().Be(renders); + RowCount(comp).Should().Be(3, "without opt-in the table only repaints when its parent re-renders"); + } + + [Test] + public void CollectionChanged_On_RerendersOnAddRemoveClear() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnCollectionChanged, true)); + var source = comp.Instance.Source; + source.SubscriberCount.Should().Be(1); + + source.Add(new TableAutoReloadTest.Item("d")); + comp.WaitForAssertion(() => RowCount(comp).Should().Be(4)); + + source.RemoveAt(0); + comp.WaitForAssertion(() => RowCount(comp).Should().Be(3)); + comp.Markup.Should().NotContain(">a<"); + + source.Clear(); + comp.WaitForAssertion(() => RowCount(comp).Should().Be(0)); + } + + [Test] + public void CollectionChanged_On_DoesNotObserveItemProperties() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnCollectionChanged, true)); + var item = comp.Instance.Source[0]; + item.SubscriberCount.Should().Be(0); + + var renders = comp.RenderCount; + item.Name = "changed"; + Thread.Sleep(50); + comp.RenderCount.Should().Be(renders); + } + + [Test] + public void ItemPropertyChanged_On_RerendersOnPropertyChange() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var item = comp.Instance.Source[1]; + item.SubscriberCount.Should().Be(1); + + item.Name = "renamed"; + comp.WaitForAssertion(() => comp.Markup.Should().Contain("renamed")); + } + + [Test] + public void ItemPropertyChanged_On_TracksItemsAddedAndRemovedLater() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var source = comp.Instance.Source; + source.SubscriberCount.Should().Be(1, "item tracking needs the collection event to follow adds/removes"); + + var added = new TableAutoReloadTest.Item("d"); + source.Add(added); + comp.WaitForAssertion(() => added.SubscriberCount.Should().Be(1)); + + // Collection tracking is only for bookkeeping here — the add itself did not re-render (flag is off) … + RowCount(comp).Should().Be(3); + // … but a property change on the new item does, and the new row shows up with it. + added.Name = "d2"; + comp.WaitForAssertion(() => comp.Markup.Should().Contain("d2")); + RowCount(comp).Should().Be(4); + + var removed = source[0]; + source.RemoveAt(0); + comp.WaitForAssertion(() => removed.SubscriberCount.Should().Be(0)); + } + + [Test] + public void Burst_IsCoalescedIntoFewRenders() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnCollectionChanged, true)); + var source = comp.Instance.Source; + var renders = comp.RenderCount; + + comp.InvokeAsync(() => + { + for (var i = 0; i < 500; i++) + { + source.Add(new TableAutoReloadTest.Item($"i{i}")); + } + }); + + comp.WaitForAssertion(() => RowCount(comp).Should().Be(503)); + (comp.RenderCount - renders).Should().BeLessThan(10, "500 notifications must not schedule 500 renders"); + } + + [Test] + public async Task Items_Swapped_MovesSubscriptionsToNewCollection() + { + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var old = comp.Instance.Source; + old.SubscriberCount.Should().Be(1); + old[0].SubscriberCount.Should().Be(1); + + var next = new TableAutoReloadTest.TrackedCollection { new TableAutoReloadTest.Item("x") }; + await comp.SetParametersAndRenderAsync(p => p.Add(x => x.Source, next)); + + old.SubscriberCount.Should().Be(0); + old[0].SubscriberCount.Should().Be(0); + next.SubscriberCount.Should().Be(1); + next[0].SubscriberCount.Should().Be(1); + RowCount(comp).Should().Be(1); + } + + [Test] + public async Task Flags_TurnedOff_Unsubscribe() + { + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var source = comp.Instance.Source; + + await comp.SetParametersAndRenderAsync(p => p + .Add(x => x.AutoReloadOnCollectionChanged, false) + .Add(x => x.AutoReloadOnItemPropertyChanged, false)); + + source.SubscriberCount.Should().Be(0); + source.Should().OnlyContain(i => i.SubscriberCount == 0); + } + + [Test] + public async Task Dispose_Unsubscribes_AndLateNotificationsAreIgnored() + { + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var source = comp.Instance.Source; + source.SubscriberCount.Should().Be(1); + + await Context.DisposeComponentsAsync(); + + source.SubscriberCount.Should().Be(0); + source.Should().OnlyContain(i => i.SubscriberCount == 0); + var act = () => { source.Add(new TableAutoReloadTest.Item("late")); source[0].Name = "late"; }; + act.Should().NotThrow(); + } + } +} diff --git a/src/MudBlazor/Components/Table/MudTable.razor.cs b/src/MudBlazor/Components/Table/MudTable.razor.cs index cc385ef4ccb4..e07c44583e32 100644 --- a/src/MudBlazor/Components/Table/MudTable.razor.cs +++ b/src/MudBlazor/Components/Table/MudTable.razor.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; @@ -30,6 +32,12 @@ public partial class MudTable<[DynamicallyAccessedMembers(DynamicallyAccessedMem private bool _currentRenderFilteredItemsCached; private CancellationTokenSource? _cancellationTokenSrc; private TableData _serverData = new() { TotalItems = 0, Items = [] }; + private INotifyCollectionChanged? _observedCollection; + private readonly HashSet _observedItems = new(ReferenceEqualityComparer.Instance); + private int _autoReloadScheduled; + private int _autoReloadRender; + private int _autoReloadResync; + private bool _disposed; [MemberNotNullWhen(true, nameof(_preEditSort))] private bool HasPreEditSort => _preEditSort is not null; @@ -240,6 +248,32 @@ public IEnumerable? Items } } + /// + /// Re-renders this table when raises . + /// + /// + /// Defaults to false. When true and implements + /// (such as ObservableCollection<T>), adding, removing or replacing items refreshes the table without + /// reassigning or calling StateHasChanged. Bursts of changes are coalesced into one render. + /// The event may be raised on any thread; the render is dispatched to the component's synchronization context. + /// Has no effect when is set. + /// + [Parameter] + [Category(CategoryTypes.Table.Behavior)] + public bool AutoReloadOnCollectionChanged { get; set; } + + /// + /// Re-renders this table when an item in raises . + /// + /// + /// Defaults to false. When true, every item implementing is observed; + /// items added to or removed from an source are tracked automatically. + /// Bursts of changes are coalesced into one render. Has no effect when is set. + /// + [Parameter] + [Category(CategoryTypes.Table.Behavior)] + public bool AutoReloadOnItemPropertyChanged { get; set; } + /// /// The function which determines whether an item should be displayed. /// @@ -918,6 +952,156 @@ private string ClearFilterCache() return ""; } + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + UpdateAutoReloadSubscriptions(); + } + + //AUTO RELOAD (INotifyCollectionChanged / INotifyPropertyChanged): + + /// + /// Aligns the collection and item subscriptions with the current and the two AutoReload parameters. + /// Idempotent; safe to call on every parameter set. + /// + private void UpdateAutoReloadSubscriptions() + { + // Item tracking also needs the collection subscription to learn about added/removed items. + var wanted = !HasServerData && (AutoReloadOnCollectionChanged || AutoReloadOnItemPropertyChanged) + ? _items as INotifyCollectionChanged + : null; + + if (!ReferenceEquals(_observedCollection, wanted)) + { + if (_observedCollection is not null) + { + _observedCollection.CollectionChanged -= OnObservedCollectionChanged; + } + + _observedCollection = wanted; + + if (wanted is not null) + { + wanted.CollectionChanged += OnObservedCollectionChanged; + } + } + + ResyncItemSubscriptions(); + } + + /// + /// Subscribes to items now present and unsubscribes from items no longer present. Enumerates once; + /// must run on the component's synchronization context. + /// + private void ResyncItemSubscriptions() + { + if (_disposed || HasServerData || !AutoReloadOnItemPropertyChanged || _items is null) + { + UnsubscribeAllItems(); + return; + } + + var current = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var item in _items) + { + if (item is INotifyPropertyChanged observable) + { + current.Add(observable); + } + } + + _observedItems.RemoveWhere(item => + { + if (current.Contains(item)) + { + return false; + } + + item.PropertyChanged -= OnObservedItemPropertyChanged; + return true; + }); + + foreach (var item in current) + { + if (_observedItems.Add(item)) + { + item.PropertyChanged += OnObservedItemPropertyChanged; + } + } + } + + private void UnsubscribeAllItems() + { + foreach (var item in _observedItems) + { + item.PropertyChanged -= OnObservedItemPropertyChanged; + } + + _observedItems.Clear(); + } + + private void OnObservedCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // May arrive on any thread. All work (item resync, render) is coalesced onto the dispatcher. + if (AutoReloadOnItemPropertyChanged) + { + Volatile.Write(ref _autoReloadResync, 1); + } + + if (AutoReloadOnCollectionChanged) + { + Volatile.Write(ref _autoReloadRender, 1); + } + + ScheduleAutoReload(); + } + + private void OnObservedItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (AutoReloadOnItemPropertyChanged) + { + Volatile.Write(ref _autoReloadRender, 1); + } + + ScheduleAutoReload(); + } + + /// + /// Runs one auto-reload pass on the next dispatcher turn for any number of notifications that arrive before it. + /// Deferring past the current turn is what coalesces a synchronous burst of changes into a single render. + /// + private void ScheduleAutoReload() + { + if (_disposed || Interlocked.CompareExchange(ref _autoReloadScheduled, 1, 0) != 0) + { + return; + } + + InvokeAsync(async () => + { + await Task.Yield(); + // Clear first: notifications raised from here on schedule the next pass. + Interlocked.Exchange(ref _autoReloadScheduled, 0); + var resync = Interlocked.Exchange(ref _autoReloadResync, 0) == 1; + var render = Interlocked.Exchange(ref _autoReloadRender, 0) == 1; + if (_disposed) + { + return; + } + + if (resync) + { + ResyncItemSubscriptions(); + } + + if (render) + { + StateHasChanged(); + } + }); + } + /// /// Releases resources used by this table. /// @@ -929,6 +1113,15 @@ public void Dispose() protected virtual void Dispose(bool disposing) { + _disposed = true; + if (_observedCollection is not null) + { + _observedCollection.CollectionChanged -= OnObservedCollectionChanged; + _observedCollection = null; + } + + UnsubscribeAllItems(); + try { _cancellationTokenSrc?.Cancel(); From cdb1ae8e19399dc57aba2f9b4baf97a88e084d33 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 24 Aug 2026 18:57:59 +0200 Subject: [PATCH 2/7] Package as ModelingEvolution.MudBlazor (drop-in: same assembly, namespace, _content/MudBlazor path) Tag-triggered publish on me/X.Y.Z[.N] to nuget.modelingevolution.com and nuget.org; upstream's v* workflow is untouched. Co-Authored-By: Claude Fable 5 --- .github/workflows/publish-me-nuget.yml | 59 ++++++++++++++++++++++++++ src/MudBlazor/MudBlazor.csproj | 13 +++++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-me-nuget.yml diff --git a/.github/workflows/publish-me-nuget.yml b/.github/workflows/publish-me-nuget.yml new file mode 100644 index 000000000000..180548f8f76f --- /dev/null +++ b/.github/workflows/publish-me-nuget.yml @@ -0,0 +1,59 @@ +# ModelingEvolution fork: publishes ModelingEvolution.MudBlazor on tags "me/X.Y.Z[.N]". +# Upstream's deploy-mudblazor-nuget.yml fires on "v*" tags only, so the two never collide. +name: publish-me-nuget + +on: + push: + tags: + - "me/[0-9]+.[0-9]+.[0-9]+*" + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#me/}" >> $GITHUB_OUTPUT + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + working-directory: src/MudBlazor + run: dotnet restore + + - name: Pack + working-directory: src/MudBlazor + run: dotnet pack -c Release --no-restore --output nupkgs /p:Version=${{ steps.version.outputs.VERSION }} + + - name: Check package contains css + shell: pwsh + run: ./tools/CheckPackageContainsStaticAssets.ps1 ./src/MudBlazor/nupkgs MudBlazor.min.css + + - name: Check package contains js + shell: pwsh + run: ./tools/CheckPackageContainsStaticAssets.ps1 ./src/MudBlazor/nupkgs MudBlazor.min.js + + - name: Push to ModelingEvolution NuGet + working-directory: src/MudBlazor + run: | + dotnet nuget push nupkgs/*.nupkg \ + --api-key ${{ secrets.NUGET_API_KEY_ME }} \ + --source https://nuget.modelingevolution.com/v3/index.json \ + --skip-duplicate + continue-on-error: true + + - name: Push to NuGet.org + working-directory: src/MudBlazor + run: | + dotnet nuget push nupkgs/*.nupkg \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/src/MudBlazor/MudBlazor.csproj b/src/MudBlazor/MudBlazor.csproj index 9c850c615ef4..8be084b91b04 100644 --- a/src/MudBlazor/MudBlazor.csproj +++ b/src/MudBlazor/MudBlazor.csproj @@ -6,6 +6,15 @@ true + + + ModelingEvolution.MudBlazor + MudBlazor + MudBlazor + MudBlazor + MIT Nuget.png @@ -13,10 +22,10 @@ Garderoben, Henon and Contributors Copyright 2026 MudBlazor Blazor, MudBlazor, Material, Material Design, Components, Blazor Components, Blazor Library - Blazor Component Library based on Material Design principles with an emphasis on ease of use and extensibility + ModelingEvolution fork of MudBlazor (drop-in, same namespaces and asset paths). Adds MudTable.AutoReloadOnCollectionChanged / AutoReloadOnItemPropertyChanged. Upstream: https://github.com/MudBlazor/MudBlazor https://mudblazor.com/ README.md - https://github.com/MudBlazor/MudBlazor + https://github.com/modelingevolution/MudBlazor git From d749d600f4c3e622cd721bcc9f1b247df1f26d26 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 24 Aug 2026 19:05:16 +0200 Subject: [PATCH 3/7] Fix StaticWebAssetBasePath: _content/MudBlazor (9.7.0.1 served assets at site root) Co-Authored-By: Claude Fable 5 --- src/MudBlazor/MudBlazor.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MudBlazor/MudBlazor.csproj b/src/MudBlazor/MudBlazor.csproj index 8be084b91b04..5fd892ec5d80 100644 --- a/src/MudBlazor/MudBlazor.csproj +++ b/src/MudBlazor/MudBlazor.csproj @@ -13,7 +13,7 @@ ModelingEvolution.MudBlazor MudBlazor MudBlazor - MudBlazor + _content/MudBlazor MIT From 2b79bb4e2376c2025d77717d3e39ab7270f62747 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Mon, 24 Aug 2026 21:30:06 +0200 Subject: [PATCH 4/7] MudTable: move AutoReloadOnItemPropertyChanged into a per-row scope (MudTableObservedRow) Each rendered row observes its own item for exactly as long as the row exists (rows are keyed on the item); a change re-renders that row only. Blazor's row lifecycle is the bookkeeping: no per-table set of subscribed items, nothing to reconcile on Reset, and only visible rows (page / virtualized window) are observed. The table keeps only the INotifyCollectionChanged subscription. MudTableObservedRow is internal and rendered from code (Razor tag syntax only discovers public component types). Co-Authored-By: Claude Fable 5 --- .../Components/TableAutoReloadTests.cs | 71 ++++++++- src/MudBlazor/Components/Table/MudTable.razor | 10 +- .../Components/Table/MudTable.razor.cs | 148 ++++-------------- .../Components/Table/MudTableObservedRow.cs | 87 ++++++++++ 4 files changed, 186 insertions(+), 130 deletions(-) create mode 100644 src/MudBlazor/Components/Table/MudTableObservedRow.cs diff --git a/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs index e670751fff48..b487f25876dd 100644 --- a/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs +++ b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs @@ -73,26 +73,65 @@ public void ItemPropertyChanged_On_RerendersOnPropertyChange() } [Test] - public void ItemPropertyChanged_On_TracksItemsAddedAndRemovedLater() + public void ItemPropertyChanged_On_ObservesOnlyRenderedRows_AndFollowsRowLifecycle() { - var comp = Context.Render(p => p.Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); var source = comp.Instance.Source; - source.SubscriberCount.Should().Be(1, "item tracking needs the collection event to follow adds/removes"); + // A row exists per item on the page → each is observed once. + source.Should().OnlyContain(i => i.SubscriberCount == 1); + + // Added item: observed as soon as its row renders … var added = new TableAutoReloadTest.Item("d"); source.Add(added); comp.WaitForAssertion(() => added.SubscriberCount.Should().Be(1)); - - // Collection tracking is only for bookkeeping here — the add itself did not re-render (flag is off) … - RowCount(comp).Should().Be(3); - // … but a property change on the new item does, and the new row shows up with it. added.Name = "d2"; comp.WaitForAssertion(() => comp.Markup.Should().Contain("d2")); - RowCount(comp).Should().Be(4); + // … removed item: its row is disposed and the subscription goes with it. var removed = source[0]; source.RemoveAt(0); comp.WaitForAssertion(() => removed.SubscriberCount.Should().Be(0)); + RowCount(comp).Should().Be(3); + } + + [Test] + public void ItemPropertyChanged_On_RerendersOnlyThatRow() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var rows = comp.FindComponents(); + Thread.Sleep(200); // let the table finish its own post-render settling + var before = rows.Select(r => r.RenderCount).ToArray(); + + comp.Instance.Source[2].Name = "only-me"; + comp.WaitForAssertion(() => comp.Markup.Should().Contain("only-me")); + + // bUnit bumps RenderCount on every ancestor whose markup changed, so the table's count is not + // evidence either way; sibling rows are — they must not have been rendered again. + rows[0].RenderCount.Should().Be(before[0]); + rows[1].RenderCount.Should().Be(before[1]); + rows[2].RenderCount.Should().BeGreaterThan(before[2]); + } + + [Test] + public void ItemPropertyChanged_Burst_IsCoalescedPerRow() + { + var comp = Context.Render(p => p.Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var item = comp.Instance.Source[0]; + var renders = comp.RenderCount; + + comp.InvokeAsync(() => + { + for (var i = 0; i < 500; i++) + { + item.Name = $"n{i}"; + } + }); + + comp.WaitForAssertion(() => comp.Markup.Should().Contain("n499")); + (comp.RenderCount - renders).Should().BeLessThan(10); } [Test] @@ -166,5 +205,21 @@ public async Task Dispose_Unsubscribes_AndLateNotificationsAreIgnored() var act = () => { source.Add(new TableAutoReloadTest.Item("late")); source[0].Name = "late"; }; act.Should().NotThrow(); } + + [Test] + public void Clear_DisposesRows_AndReleasesItemSubscriptions() + { + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var source = comp.Instance.Source; + var items = source.ToList(); + items.Should().OnlyContain(i => i.SubscriberCount == 1); + + source.Clear(); // Reset: no OldItems — the row lifecycle, not event args, releases the subscriptions + + comp.WaitForAssertion(() => RowCount(comp).Should().Be(0)); + comp.WaitForAssertion(() => items.Should().OnlyContain(i => i.SubscriberCount == 0)); + } } } diff --git a/src/MudBlazor/Components/Table/MudTable.razor b/src/MudBlazor/Components/Table/MudTable.razor index 8c288e1af043..4eda4370d2a7 100644 --- a/src/MudBlazor/Components/Table/MudTable.razor +++ b/src/MudBlazor/Components/Table/MudTable.razor @@ -101,6 +101,7 @@ if (CurrentPageItems != null && CurrentPageItems.Any()) { + @ObservedRow(item, @ @{ var itemIndex = FilteredItems.ToList().IndexOf(item); var generatedRowId = itemIndex != -1 ? $"{_tableId}_row_{itemIndex}" : null; @@ -110,7 +111,7 @@ .Build(); var rowStyle = new StyleBuilder().AddStyle(RowStyle).AddStyle(RowStyleFunc?.Invoke(item, itemIndex)).Build(); } - @@ -144,6 +145,7 @@ { @ChildRowContent(item) } + ) } } @@ -216,7 +218,9 @@ ; - RenderFragment child() => item => + RenderFragment child() => item => ObservedRow(item, rowContent()(item)); + + RenderFragment rowContent() => item => @ @{ var itemIndexForId = FilteredItems.ToList().IndexOf(item); @@ -225,7 +229,7 @@ var rowClass = new CssBuilder(RowClass).AddClass(RowClassFunc?.Invoke(item, itemIndexForId)).AddClass(customClass, !string.IsNullOrEmpty(customClass)).AddClass("mud-table-row-clickable", OnRowClick.HasDelegate && !IsRowDisabled(item)).Build(); var rowStyle = new StyleBuilder().AddStyle(RowStyle).AddStyle(RowStyleFunc?.Invoke(item, itemIndexForId)).Build(); } - @if (!ReadOnly && Editable && Equals(_editingItem, item)) diff --git a/src/MudBlazor/Components/Table/MudTable.razor.cs b/src/MudBlazor/Components/Table/MudTable.razor.cs index e07c44583e32..d9b71a17a719 100644 --- a/src/MudBlazor/Components/Table/MudTable.razor.cs +++ b/src/MudBlazor/Components/Table/MudTable.razor.cs @@ -33,10 +33,7 @@ public partial class MudTable<[DynamicallyAccessedMembers(DynamicallyAccessedMem private CancellationTokenSource? _cancellationTokenSrc; private TableData _serverData = new() { TotalItems = 0, Items = [] }; private INotifyCollectionChanged? _observedCollection; - private readonly HashSet _observedItems = new(ReferenceEqualityComparer.Instance); - private int _autoReloadScheduled; - private int _autoReloadRender; - private int _autoReloadResync; + private int _autoReloadPending; private bool _disposed; [MemberNotNullWhen(true, nameof(_preEditSort))] @@ -266,9 +263,10 @@ public IEnumerable? Items /// Re-renders this table when an item in raises . /// /// - /// Defaults to false. When true, every item implementing is observed; - /// items added to or removed from an source are tracked automatically. - /// Bursts of changes are coalesced into one render. Has no effect when is set. + /// Defaults to false. When true, each rendered row observes its own item for as long as the row exists + /// (rows are keyed on the item), and a change re-renders that row only. Items not currently rendered — other pages, + /// outside the virtualized window — are not observed, so cost is bounded by visible rows, not by the collection. + /// Bursts of changes are coalesced into one render per row. /// [Parameter] [Category(CategoryTypes.Table.Behavior)] @@ -959,121 +957,48 @@ protected override void OnParametersSet() UpdateAutoReloadSubscriptions(); } - //AUTO RELOAD (INotifyCollectionChanged / INotifyPropertyChanged): + //AUTO RELOAD (INotifyCollectionChanged; per-item INotifyPropertyChanged lives in MudTableObservedRow): /// - /// Aligns the collection and item subscriptions with the current and the two AutoReload parameters. - /// Idempotent; safe to call on every parameter set. + /// Wraps one row in a scope keyed on the item. Rendered from code because + /// the component is internal and Razor tag syntax only discovers public component types. /// - private void UpdateAutoReloadSubscriptions() + private RenderFragment ObservedRow(T item, RenderFragment content) => builder => { - // Item tracking also needs the collection subscription to learn about added/removed items. - var wanted = !HasServerData && (AutoReloadOnCollectionChanged || AutoReloadOnItemPropertyChanged) - ? _items as INotifyCollectionChanged - : null; - - if (!ReferenceEquals(_observedCollection, wanted)) - { - if (_observedCollection is not null) - { - _observedCollection.CollectionChanged -= OnObservedCollectionChanged; - } - - _observedCollection = wanted; + builder.OpenComponent>(0); + builder.SetKey(item); + builder.AddComponentParameter(1, nameof(MudTableObservedRow.Item), item); + builder.AddComponentParameter(2, nameof(MudTableObservedRow.Enabled), AutoReloadOnItemPropertyChanged); + builder.AddComponentParameter(3, nameof(MudTableObservedRow.ChildContent), content); + builder.CloseComponent(); + }; - if (wanted is not null) - { - wanted.CollectionChanged += OnObservedCollectionChanged; - } - } - - ResyncItemSubscriptions(); - } - - /// - /// Subscribes to items now present and unsubscribes from items no longer present. Enumerates once; - /// must run on the component's synchronization context. - /// - private void ResyncItemSubscriptions() + private void UpdateAutoReloadSubscriptions() { - if (_disposed || HasServerData || !AutoReloadOnItemPropertyChanged || _items is null) + var wanted = !HasServerData && AutoReloadOnCollectionChanged ? _items as INotifyCollectionChanged : null; + if (ReferenceEquals(_observedCollection, wanted)) { - UnsubscribeAllItems(); return; } - var current = new HashSet(ReferenceEqualityComparer.Instance); - foreach (var item in _items) + if (_observedCollection is not null) { - if (item is INotifyPropertyChanged observable) - { - current.Add(observable); - } + _observedCollection.CollectionChanged -= OnObservedCollectionChanged; } - _observedItems.RemoveWhere(item => - { - if (current.Contains(item)) - { - return false; - } - - item.PropertyChanged -= OnObservedItemPropertyChanged; - return true; - }); - - foreach (var item in current) - { - if (_observedItems.Add(item)) - { - item.PropertyChanged += OnObservedItemPropertyChanged; - } - } - } + _observedCollection = wanted; - private void UnsubscribeAllItems() - { - foreach (var item in _observedItems) + if (wanted is not null) { - item.PropertyChanged -= OnObservedItemPropertyChanged; + wanted.CollectionChanged += OnObservedCollectionChanged; } - - _observedItems.Clear(); } private void OnObservedCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - // May arrive on any thread. All work (item resync, render) is coalesced onto the dispatcher. - if (AutoReloadOnItemPropertyChanged) - { - Volatile.Write(ref _autoReloadResync, 1); - } - - if (AutoReloadOnCollectionChanged) - { - Volatile.Write(ref _autoReloadRender, 1); - } - - ScheduleAutoReload(); - } - - private void OnObservedItemPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (AutoReloadOnItemPropertyChanged) - { - Volatile.Write(ref _autoReloadRender, 1); - } - - ScheduleAutoReload(); - } - - /// - /// Runs one auto-reload pass on the next dispatcher turn for any number of notifications that arrive before it. - /// Deferring past the current turn is what coalesces a synchronous burst of changes into a single render. - /// - private void ScheduleAutoReload() - { - if (_disposed || Interlocked.CompareExchange(ref _autoReloadScheduled, 1, 0) != 0) + // May arrive on any thread. Render on the next dispatcher turn, once for any number of notifications + // that arrive before it — deferring past the current turn is what coalesces a synchronous burst. + if (_disposed || Interlocked.CompareExchange(ref _autoReloadPending, 1, 0) != 0) { return; } @@ -1081,21 +1006,8 @@ private void ScheduleAutoReload() InvokeAsync(async () => { await Task.Yield(); - // Clear first: notifications raised from here on schedule the next pass. - Interlocked.Exchange(ref _autoReloadScheduled, 0); - var resync = Interlocked.Exchange(ref _autoReloadResync, 0) == 1; - var render = Interlocked.Exchange(ref _autoReloadRender, 0) == 1; - if (_disposed) - { - return; - } - - if (resync) - { - ResyncItemSubscriptions(); - } - - if (render) + Interlocked.Exchange(ref _autoReloadPending, 0); + if (!_disposed) { StateHasChanged(); } @@ -1120,8 +1032,6 @@ protected virtual void Dispose(bool disposing) _observedCollection = null; } - UnsubscribeAllItems(); - try { _cancellationTokenSrc?.Cancel(); diff --git a/src/MudBlazor/Components/Table/MudTableObservedRow.cs b/src/MudBlazor/Components/Table/MudTableObservedRow.cs new file mode 100644 index 000000000000..d9b78611ce60 --- /dev/null +++ b/src/MudBlazor/Components/Table/MudTableObservedRow.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace MudBlazor; + +/// +/// Internal per-row scope for : observes one item's +/// for exactly as long as the row exists, and re-renders +/// only this row when it fires. Blazor's row lifecycle (keyed on the item) is the bookkeeping: a row that +/// leaves the page, the virtualized window, or the table is disposed and its subscription goes with it — so +/// there is no per-table set of subscribed items and nothing to reconcile on Reset. +/// +/// The item type. +internal sealed class MudTableObservedRow : ComponentBase, IDisposable +{ + private INotifyPropertyChanged? _observed; + private int _renderPending; + private bool _disposed; + + /// The row's item. + [Parameter] + public T Item { get; set; } = default!; + + /// When false, nothing is observed and the row renders exactly as without this scope. + [Parameter] + public bool Enabled { get; set; } + + /// The row content. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + protected override void OnParametersSet() + { + var wanted = Enabled ? Item as INotifyPropertyChanged : null; + if (ReferenceEquals(_observed, wanted)) + { + return; + } + + if (_observed is not null) + { + _observed.PropertyChanged -= OnItemPropertyChanged; + } + + _observed = wanted; + + if (wanted is not null) + { + wanted.PropertyChanged += OnItemPropertyChanged; + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) => builder.AddContent(0, ChildContent); + + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // May arrive on any thread; render on the next dispatcher turn, once per burst. + if (_disposed || Interlocked.CompareExchange(ref _renderPending, 1, 0) != 0) + { + return; + } + + _ = InvokeAsync(async () => + { + await Task.Yield(); + Interlocked.Exchange(ref _renderPending, 0); + if (!_disposed) + { + StateHasChanged(); + } + }); + } + + /// + public void Dispose() + { + _disposed = true; + if (_observed is not null) + { + _observed.PropertyChanged -= OnItemPropertyChanged; + _observed = null; + } + } +} From d04388dabf1dc3c02fa0c8aae35a77f242e19e27 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Tue, 25 Aug 2026 11:52:48 +0200 Subject: [PATCH 5/7] Fix file encoding (utf-8-bom per .editorconfig) Co-Authored-By: Claude Fable 5 --- .../TestComponents/Table/TableAutoReloadTest.razor | 2 +- src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs | 2 +- src/MudBlazor/Components/Table/MudTableObservedRow.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor index 0dc3c452399d..073f0c878aec 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableAutoReloadTest.razor @@ -1,4 +1,4 @@ -@using System.Collections.Specialized +@using System.Collections.Specialized @using System.ComponentModel diff --git a/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs index b487f25876dd..b1a02f8b626d 100644 --- a/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs +++ b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs @@ -1,4 +1,4 @@ -using AwesomeAssertions; +using AwesomeAssertions; using Bunit; using MudBlazor.UnitTests.TestComponents.Table; using NUnit.Framework; diff --git a/src/MudBlazor/Components/Table/MudTableObservedRow.cs b/src/MudBlazor/Components/Table/MudTableObservedRow.cs index d9b78611ce60..1e8b511a6eef 100644 --- a/src/MudBlazor/Components/Table/MudTableObservedRow.cs +++ b/src/MudBlazor/Components/Table/MudTableObservedRow.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; From b752c4bc070c1c2aa5578a24b0aa6297290fda90 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Wed, 12 Aug 2026 16:46:28 +0200 Subject: [PATCH 6/7] Tests: Guard null picker reference in DateRangePickerMinMaxDaysTest (#13614) (cherry picked from commit 029a5926e702154b1b6cc89175379cb1f5e04284) --- .../DatePicker/DateRangePickerMinMaxDaysTest.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DatePicker/DateRangePickerMinMaxDaysTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DatePicker/DateRangePickerMinMaxDaysTest.razor index e9c069e962d1..018626c8db4b 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DatePicker/DateRangePickerMinMaxDaysTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DatePicker/DateRangePickerMinMaxDaysTest.razor @@ -9,7 +9,7 @@ AdditionalDateClassesFunc="@((DateTime dt)=>((int)dt.DayOfWeek == 0 ? "red-text text-accent-4" : ""))" /> - Allow Weekends + Allow Weekends Include Disabled From 2646ec7a14710f4d213764d68f5a312ff0a28be3 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Tue, 25 Aug 2026 13:47:48 +0200 Subject: [PATCH 7/7] Supersede upstream MudBlazor in every consumer (buildTransitive targets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer that also receives the upstream MudBlazor package — typically transitively via MudBlazor.Markdown or Extensions.MudBlazor.StaticInput — got two MudBlazor.dll and 'Conflicting assets with the same target path _content/MudBlazor/MudBlazor.min.css'. The packed buildTransitive targets now drop the upstream package's compile/runtime/static-web assets in any project that references ModelingEvolution.MudBlazor directly or transitively, so no per-consumer override is needed. Opt out: ModelingEvolutionMudBlazorSupersedesUpstream=false. Verified with a fresh web host referencing MudBlazor.Markdown 9.0.0 and no override: builds, deps.json lists only the fork, one MudBlazor.dll, assets served from the fork. Co-Authored-By: Claude Fable 5 --- src/MudBlazor/MudBlazor.csproj | 1 + .../ModelingEvolution.MudBlazor.targets | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/MudBlazor/buildTransitive/ModelingEvolution.MudBlazor.targets diff --git a/src/MudBlazor/MudBlazor.csproj b/src/MudBlazor/MudBlazor.csproj index 5fd892ec5d80..ddf9520afbc2 100644 --- a/src/MudBlazor/MudBlazor.csproj +++ b/src/MudBlazor/MudBlazor.csproj @@ -53,6 +53,7 @@ + diff --git a/src/MudBlazor/buildTransitive/ModelingEvolution.MudBlazor.targets b/src/MudBlazor/buildTransitive/ModelingEvolution.MudBlazor.targets new file mode 100644 index 000000000000..c371ee11ff12 --- /dev/null +++ b/src/MudBlazor/buildTransitive/ModelingEvolution.MudBlazor.targets @@ -0,0 +1,41 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + +