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.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 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..073f0c878aec --- /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..b1a02f8b626d --- /dev/null +++ b/src/MudBlazor.UnitTests/Components/TableAutoReloadTests.cs @@ -0,0 +1,225 @@ +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_ObservesOnlyRenderedRows_AndFollowsRowLifecycle() + { + var comp = Context.Render(p => p + .Add(x => x.AutoReloadOnCollectionChanged, true) + .Add(x => x.AutoReloadOnItemPropertyChanged, true)); + var source = comp.Instance.Source; + + // 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)); + added.Name = "d2"; + comp.WaitForAssertion(() => comp.Markup.Should().Contain("d2")); + + // … 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] + 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(); + } + + [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 cc385ef4ccb4..d9b71a17a719 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,9 @@ public partial class MudTable<[DynamicallyAccessedMembers(DynamicallyAccessedMem private bool _currentRenderFilteredItemsCached; private CancellationTokenSource? _cancellationTokenSrc; private TableData _serverData = new() { TotalItems = 0, Items = [] }; + private INotifyCollectionChanged? _observedCollection; + private int _autoReloadPending; + private bool _disposed; [MemberNotNullWhen(true, nameof(_preEditSort))] private bool HasPreEditSort => _preEditSort is not null; @@ -240,6 +245,33 @@ 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, 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)] + public bool AutoReloadOnItemPropertyChanged { get; set; } + /// /// The function which determines whether an item should be displayed. /// @@ -918,6 +950,70 @@ private string ClearFilterCache() return ""; } + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + UpdateAutoReloadSubscriptions(); + } + + //AUTO RELOAD (INotifyCollectionChanged; per-item INotifyPropertyChanged lives in MudTableObservedRow): + + /// + /// 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 RenderFragment ObservedRow(T item, RenderFragment content) => builder => + { + 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(); + }; + + private void UpdateAutoReloadSubscriptions() + { + var wanted = !HasServerData && AutoReloadOnCollectionChanged ? _items as INotifyCollectionChanged : null; + if (ReferenceEquals(_observedCollection, wanted)) + { + return; + } + + if (_observedCollection is not null) + { + _observedCollection.CollectionChanged -= OnObservedCollectionChanged; + } + + _observedCollection = wanted; + + if (wanted is not null) + { + wanted.CollectionChanged += OnObservedCollectionChanged; + } + } + + private void OnObservedCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // 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; + } + + InvokeAsync(async () => + { + await Task.Yield(); + Interlocked.Exchange(ref _autoReloadPending, 0); + if (!_disposed) + { + StateHasChanged(); + } + }); + } + /// /// Releases resources used by this table. /// @@ -929,6 +1025,13 @@ public void Dispose() protected virtual void Dispose(bool disposing) { + _disposed = true; + if (_observedCollection is not null) + { + _observedCollection.CollectionChanged -= OnObservedCollectionChanged; + _observedCollection = null; + } + 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..1e8b511a6eef --- /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; + } + } +} diff --git a/src/MudBlazor/MudBlazor.csproj b/src/MudBlazor/MudBlazor.csproj index 9c850c615ef4..ddf9520afbc2 100644 --- a/src/MudBlazor/MudBlazor.csproj +++ b/src/MudBlazor/MudBlazor.csproj @@ -6,6 +6,15 @@ true + + + ModelingEvolution.MudBlazor + MudBlazor + MudBlazor + _content/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 @@ -44,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 + + + + + + + + + + + + + + + + + + + + +