From c087e4534548339e590c4452dbe95a368a626af3 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:26:44 -0500 Subject: [PATCH 1/2] fix(maui): keep RecycledItemViewLimit(0) from watching a list, and document the option Passing 0 to a list that had never opted in created a watcher and a PropertyChanged subscription for a list that keeps nothing. Adds the README section for the option, tests that a list which has not opted in is not watched and that opting in after rows are bound takes effect, and puts every MAUI test class through UseStellarComponents so results cannot depend on which class initialises ReactiveUI first. Co-Authored-By: Claude Fable 5.1 --- README.md | 23 ++++++++++ .../ItemsViewRecyclingTests.cs | 42 +++++++++++++++++++ Stellar.Maui.UnitTests/MauiTestBase.cs | 14 ++----- Stellar.Maui.UnitTests/StellarTestBase.cs | 3 ++ .../Extensions/ItemsViewExtensions.cs | 9 ++++ Stellar.Maui/MauiViewManager.cs | 2 +- 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c6a19d1..b2931ab 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,29 @@ var removed = await _cache.RemoveAsync(cacheKey: "my-key"); await _cache.ClearCacheAsync("group"); // null clears the default group ``` +### Keeping Recycled List Rows Bound (MAUI) + +A `CollectionView` reuses its row views. By default StellarUI tears a row's bindings down +when the row is recycled and builds them again when it is reused. A list can opt in to +keeping a number of recycled rows bound instead, which removes that work while scrolling: + +```csharp +var list = new CollectionView().RecycledItemViewLimit(5); +``` + +- The default is `0`: nothing is kept, and a list that never calls the method is unaffected. +- `5` matches the number of views an Android `RecyclerView` pool holds per view type. If you + have resized that pool, pass the same number here. +- Kept rows are released when the list leaves its window, or when the limit is lowered. +- Only rows whose view maps a data model onto its own view model are kept + (`GridBase` and the other two-parameter base classes). A row that + is handed a view model per item is torn down as before. + +A kept row does **not** run `Bind`, `Initialized`, `Activated` or `Deactivated` again when it +is given its next item; it only receives `Detached` and `Attached`, and its view model's +item changes. Opt in only for rows that take everything they show from bindings to the view +model. A row that reads its item imperatively inside `Bind` would show the previous item. + ### Hot Reload StellarUI has built-in support for hot reload during development: diff --git a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs index 2f252f2..9be45ae 100644 --- a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs +++ b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs @@ -357,6 +357,48 @@ public void ListThatHasNotOptedIn_TearsEveryViewDownAsBefore() Assert.Equal(0, source.HandlerCount); } + [Fact] + public void ListThatHasNotOptedIn_IsNotWatched() + { + var source = new LongLivedSource(); + var list = new ListHarness(recycledItemViewLimit: null); + var cells = BindCells(list, source, 2); + + foreach (var cell in cells) + { + list.Recycle(cell); + } + + Assert.Null(ItemsHostWatcher.Find(list.List)); + } + + [Fact] + public void RecycledItemViewLimit_OfZero_OnAListThatHasNotOptedIn_DoesNotWatchIt() + { + var list = new ListHarness(recycledItemViewLimit: null); + + list.List.RecycledItemViewLimit(0); + + Assert.Null(ItemsHostWatcher.Find(list.List)); + } + + [Fact] + public void RecycledItemViewLimit_SetAfterViewsAreBound_KeepsThemFromTheirNextRecycle() + { + var source = new LongLivedSource(); + var list = new ListHarness(recycledItemViewLimit: null); + var cells = BindCells(list, source, 2); + + list.List.RecycledItemViewLimit(Cap); + + foreach (var cell in cells) + { + list.Recycle(cell); + } + + Assert.All(cells, static cell => Assert.True(cell.ViewManager.ControlsBound)); + } + [Fact] public void RecycledItemViewLimit_Lowered_ReleasesTheViewsAlreadyKept() { diff --git a/Stellar.Maui.UnitTests/MauiTestBase.cs b/Stellar.Maui.UnitTests/MauiTestBase.cs index aeaaad1..24e6777 100644 --- a/Stellar.Maui.UnitTests/MauiTestBase.cs +++ b/Stellar.Maui.UnitTests/MauiTestBase.cs @@ -1,19 +1,11 @@ -using ReactiveUI.Reactive.Builder; - namespace Stellar.Maui.UnitTests; -public abstract class MauiTestBase +public abstract class MauiTestBase : StellarTestBase { - // RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP, which the view manager mirrors. + // The limit ListHarness opts its list in with, which is + // RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP. protected const int Cap = 5; - static MauiTestBase() - { - // ReactiveUI throws on first use unless it has been built. This is the same - // registration UseStellarComponents performs, without a MauiAppBuilder. - RxAppBuilder.CreateReactiveUIBuilder().WithMaui().BuildApp(); - } - public static TheoryData CellKinds => new() { diff --git a/Stellar.Maui.UnitTests/StellarTestBase.cs b/Stellar.Maui.UnitTests/StellarTestBase.cs index 2b076bc..790e8c8 100644 --- a/Stellar.Maui.UnitTests/StellarTestBase.cs +++ b/Stellar.Maui.UnitTests/StellarTestBase.cs @@ -4,6 +4,9 @@ namespace Stellar.Maui.UnitTests; public abstract class StellarTestBase { + // ReactiveUI throws on first use unless it has been built. Every test class goes + // through the registration an app performs, so the result cannot depend on which + // class happens to run first. static StellarTestBase() { MauiApp.CreateBuilder(useDefaults: false).UseStellarComponents(); diff --git a/Stellar.Maui/Extensions/ItemsViewExtensions.cs b/Stellar.Maui/Extensions/ItemsViewExtensions.cs index 7af481e..17fbbc6 100644 --- a/Stellar.Maui/Extensions/ItemsViewExtensions.cs +++ b/Stellar.Maui/Extensions/ItemsViewExtensions.cs @@ -27,6 +27,15 @@ public static TItemsView RecycledItemViewLimit(this TItemsView items ArgumentNullException.ThrowIfNull(itemsView); ArgumentOutOfRangeException.ThrowIfNegative(limit); + // Zero is what a list that never opted in already does, so it must not be what + // gives the list a watcher. + if (limit == 0) + { + ItemsHostWatcher.Find(itemsView)?.Limit = 0; + + return itemsView; + } + ItemsHostWatcher.For(itemsView).Limit = limit; return itemsView; diff --git a/Stellar.Maui/MauiViewManager.cs b/Stellar.Maui/MauiViewManager.cs index 78f542a..172b0ac 100644 --- a/Stellar.Maui/MauiViewManager.cs +++ b/Stellar.Maui/MauiViewManager.cs @@ -141,7 +141,7 @@ private bool TryPark(VisualElement element, IStellarView view) } // A list has a watcher only once it has opted in through RecycledItemViewLimit, - // so a list that has not costs nothing here. + // so for a list that has not this lookup is the whole cost of a recycle. if (ItemsHostWatcher.Find(host) is not { } watcher) { return false; From 3e30b6adf81c0961f8f52029b0cef3c714a002a4 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:55:50 -0500 Subject: [PATCH 2/2] fix(maui): deliver Deactivated to the view model a parked view was activated with When a kept recycled view's view model was replaced while it was parked, Deactivated went to the replacement, which had never been activated, and the original got only Unregister. ReactiveUI deactivates the old view model before activating the new one; this follows that order. ViewManager gains a protected OnLifecycle(TViewModel?, LifecycleEvent) so a derived manager can address a view model the view no longer holds. Co-Authored-By: Claude Fable 5.1 --- .../ItemsViewRecyclingTests.cs | 127 ++++++++++++++++++ Stellar.Maui/MauiViewManager.cs | 27 +++- Stellar/ViewManager.cs | 15 ++- 3 files changed, 163 insertions(+), 6 deletions(-) diff --git a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs index 9be45ae..d103803 100644 --- a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs +++ b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs @@ -1,3 +1,6 @@ +using System.Runtime.CompilerServices; +using Stellar.UnitTests; + namespace Stellar.Maui.UnitTests; /// @@ -302,6 +305,115 @@ public void RecycledView_WhoseViewModelWasReplacedMeanwhile_LetsGoOfBothWhenTheL Assert.Equal(0, source.HandlerCount); } + [Fact] + public void RecycledView_WhoseViewModelWasReplacedMeanwhile_DeactivatesTheViewModelItActivatedWhenItReturns() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + var replacement = new TestCellViewModel(source); + list.Recycle(cell); + cell.ViewModel = replacement; + cell.Events.Clear(); + original.Events.Clear(); + + list.Bind(cell, new TestItem("Table", 7)); + + Assert.Equal(new[] { LifecycleEvent.Deactivated }, original.Events); + Assert.Equal( + new[] { LifecycleEvent.Initialized, LifecycleEvent.Activated, LifecycleEvent.Attached }, + replacement.Events); + Assert.Equal( + new[] + { + LifecycleEvent.Deactivated, + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + }, + cell.Events); + } + + [Fact] + public void RecycledView_WhoseViewModelWasReplacedMeanwhile_DeactivatesTheViewModelItActivatedWhenTheListLeavesTheWindow() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + var replacement = new TestCellViewModel(source); + list.Recycle(cell); + cell.ViewModel = replacement; + cell.Events.Clear(); + original.Events.Clear(); + + list.RemoveFromWindow(); + + Assert.Equal(new[] { LifecycleEvent.Deactivated }, original.Events); + Assert.Empty(replacement.Events); + Assert.Equal(new[] { LifecycleEvent.Deactivated }, cell.Events); + } + + [Fact] + public void RecycledView_WhoseViewModelWasTakenAwayMeanwhile_DeactivatesTheViewModelItActivated() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + list.Recycle(cell); + cell.ViewModel = null; + cell.Events.Clear(); + + list.RemoveFromWindow(); + + Assert.Equal( + new[] + { + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + LifecycleEvent.Detached, + LifecycleEvent.Deactivated, + }, + original.Events); + Assert.Equal(new[] { LifecycleEvent.Deactivated }, cell.Events); + Assert.False(original.BindingsRegistered); + Assert.False(cell.ViewManager.ControlsBound); + Assert.Equal(0, source.HandlerCount); + } + + [Fact] + public void RecycledView_WhoseReplacedViewModelIsNoLongerAlive_RaisesDeactivatedOnceAndOnlyOnTheView() + { + var list = new ListHarness(); + var cell = new GridDataModelCell(); + var replacement = new TestCellViewModel(); + var original = BindRecycleAndReplace(list, cell, replacement); + GcHelpers.ForceFullCollection(); + cell.Events.Clear(); + + list.Bind(cell, new TestItem("Table", 7)); + + Assert.False(original.IsAlive); + Assert.Equal( + new[] + { + LifecycleEvent.Deactivated, + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + }, + cell.Events); + Assert.Equal( + new[] { LifecycleEvent.Initialized, LifecycleEvent.Activated, LifecycleEvent.Attached }, + replacement.Events); + } + [Fact] public void RecyclingMoreViewsThanARecyclerRetains_TearsDownTheRestImmediately() { @@ -892,6 +1004,21 @@ public void RecycledView_ReturningToADifferentList_IsCountedThereFromThenOn() Assert.Equal(0, source.HandlerCount); } + // Not inlined, so that no local of the caller keeps the original view model alive. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference BindRecycleAndReplace( + ListHarness list, + GridDataModelCell cell, + TestCellViewModel replacement) + { + list.BindNew(cell, new TestItem("Chair", 3)); + var original = new WeakReference(cell.ViewModel); + list.Recycle(cell); + cell.ViewModel = replacement; + + return original; + } + private static List BindCells( ListHarness list, LongLivedSource source, diff --git a/Stellar.Maui/MauiViewManager.cs b/Stellar.Maui/MauiViewManager.cs index 172b0ac..d292e40 100644 --- a/Stellar.Maui/MauiViewManager.cs +++ b/Stellar.Maui/MauiViewManager.cs @@ -58,7 +58,7 @@ public override void PropertyChanged(TView view, string? propertyName = n return; } - this.Deactivate(isv, parkedViewModel); + this.DeactivateParked(isv, parkedViewModel); } this.HandleActivated(isv); @@ -93,7 +93,7 @@ void IParkedItemView.DeactivateParked() if (this.Unpark(out var parkedViewModel) && view is not null) { - this.Deactivate(view, parkedViewModel); + this.DeactivateParked(view, parkedViewModel); } } @@ -177,11 +177,30 @@ private bool Unpark(out TViewModel? parkedViewModel) return true; } - private void Deactivate(IStellarView view, TViewModel? parkedViewModel = null) + private void Deactivate(IStellarView view) { this.HandleDeactivated(view); - if (parkedViewModel is ViewModelBase replaced && !ReferenceEquals(replaced, view.ViewModel)) + view.DisposeView(); + } + + private void DeactivateParked(IStellarView view, TViewModel? parkedViewModel) + { + if (parkedViewModel is not null && ReferenceEquals(parkedViewModel, view.ViewModel)) + { + this.Deactivate(view); + return; + } + + // The view was given another view model while it was recycled. The one it was + // parked with is the one that was activated, so Deactivated is its to receive. The + // one the view has now was never activated: it is either about to be, or is being + // let go with the view. + this.OnLifecycle(parkedViewModel, LifecycleEvent.Deactivated); + + this.UnregisterBindings(view); + + if (parkedViewModel is ViewModelBase replaced) { replaced.Unregister(); } diff --git a/Stellar/ViewManager.cs b/Stellar/ViewManager.cs index b8a9b3b..58f9c05 100644 --- a/Stellar/ViewManager.cs +++ b/Stellar/ViewManager.cs @@ -301,14 +301,25 @@ public virtual void PropertyChanged(TView view, string? propertyName = nu } } - public void OnLifecycle(IStellarView view, LifecycleEvent lifecycleEvent) + public void OnLifecycle(IStellarView view, LifecycleEvent lifecycleEvent) => + OnLifecycle(view.ViewModel, lifecycleEvent); + + /// + /// Raises a lifecycle event to a view model and to this manager's lifecycle stream. The + /// view model is a parameter so that a derived manager can address one the view no + /// longer holds, such as the view model a recycled view was activated with before it + /// was given another. + /// + /// The view model to notify, if it is . + /// The event. + protected void OnLifecycle(TViewModel? viewModel, LifecycleEvent lifecycleEvent) { if (Volatile.Read(ref _isDisposed)) { return; // Silently return if disposed to avoid exceptions during cleanup } - if (view.ViewModel is ILifecycleEventAware lea) + if (viewModel is ILifecycleEventAware lea) { lea.OnLifecycleEvent(lifecycleEvent); }