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] 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;