Skip to content

perf(maui): track a recycled view's list without a weak reference per item view - #56

Open
michaelstonis wants to merge 3 commits into
developfrom
perf/maui-recycling-allocations
Open

michaelstonis wants to merge 3 commits into
developfrom
perf/maui-recycling-allocations

Conversation

@michaelstonis

Copy link
Copy Markdown
Contributor

Stacked on #54 (its commit shows here until it merges).

Why

Every item view of every ItemsView allocated a WeakReference<ItemsView?> the first time it was parented, opted in or not: 24 bytes plus a GC handle and a finalizer per row. The reason the list has to be remembered at all is in the MAUI source: Element.SetParent clears RealParent before OnParentSet propagates the window loss, so when a view learns its window is gone its parent is already null. TryPark also built the Parking object (three more weak references) before the watcher could refuse the view.

Full reasoning, MAUI/AndroidX citations and measurements are in review-follow-ups-research-2026-10-05.md, added here.

What

  • The view manager remembers the list's ItemsHostWatcher (which already holds the list weakly) instead of its own weak reference. A static ItemsHostWatcher.None stands in for lists that have not opted in, and Find short-circuits on a static counter while no list in the process has opted in.
  • TryPark lets the watcher take the slot first; the slot's weak reference and the Parking are created only once a view has actually been kept.
  • The Lock in ItemsHostWatcher stays: every library path is on the UI thread, but it guards Dispose and limit changes from app code, and uncontended it is a CAS.

Behaviour change: a list that opts in after its rows are bound now keeps each row from its next bind, not its next recycle (a row learns its list's watcher when bound). README and XML docs say to set the limit before the list shows rows. The #54 test for late opt-in is renamed and adjusted accordingly.

Measured

  • First-tracking allocation per item view: 24 B → 0 B, opted in or not (the committed test fails on the base with "Expected 0, Actual 24").
  • Coarse harness: first bind per row 3990 → 3966 B; a kept view's recycle+rebind cycle 3048 B and a torn-down cycle 21307 B, both unchanged.
  • The GC handle and finalizer per row are removed but not measured in bytes.

Verification

🤖 Generated with Claude Code

michaelstonis and others added 3 commits October 5, 2026 16:26
…cument 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 <noreply@anthropic.com>
… item view

Every item view of every ItemsView, opted in or not, allocated a
WeakReference to its list the first time it got a parent, because by the
time MAUI reports the window gone the parent has already been cleared. The
view manager now remembers the list's ItemsHostWatcher instead, which holds
the list weakly itself, with a shared None standing in for a list that has
not opted in. That is 24 bytes, a GC handle and a finalizer less per item
view (measured: 24 B to 0 B on the first parent change), and a recycle no
longer looks the watcher up.

TryPark no longer builds the Parking and its weak references before the
watcher has accepted the view: the watcher creates the slot's weak reference
only when it takes the view, under the same lock, and hands it back for the
manager to keep. A view refused by a full list allocates nothing.

Find skips the table lookup until some list has opted in, so an app that
never calls RecycledItemViewLimit pays a static read per bind.

A view now learns on bind, not on recycle, whether its list keeps recycled
views, so a view bound before its list opted in is torn down once more and
kept from its next bind on; the test, the README and the XML docs say so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The public documentation misstates cross-platform late opt-in behavior, and the research verdict contradicts its described core change.

Review effort: Balanced
Findings: 3 Low severity

Open (3)
What changed in this PR

Optimizes MAUI recycled-row tracking by eliminating unnecessary per-view weak references and deferred parking allocations.

Changes:

  • Shares list watchers through a sentinel-backed lookup.
  • Allocates parking state only for accepted views.
  • Adds allocation tests and lifecycle documentation.
File Description
Stellar.Maui/​MauiViewManager.cs Tracks shared list watchers and defers parking allocation.
Stellar.Maui/​ItemsHostWatcher.cs Adds sentinel, host tracking, and lazy slots.
Stellar.Maui/​Extensions/​ItemsViewExtensions.cs Avoids watcher creation for a zero limit.
Stellar.Maui.UnitTests/​StellarTestBase.cs Documents shared ReactiveUI initialization.
Stellar.Maui.UnitTests/​MauiTestBase.cs Uses common test initialization.
Stellar.Maui.UnitTests/​ItemsViewRecyclingTests.cs Covers allocations and late opt-in behavior.
review-follow-ups-research-2026-10-05.md Records research and measurements.
README.md Documents recycled-row retention.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread README.md
Comment on lines +526 to +528
- Set the limit before the list shows its rows. A row learns whether its list keeps rows
when it is bound, so a row that was already bound when the limit was raised is torn down
once more and kept from the next time it is bound.
Comment on lines +18 to +21
/// views that take everything they show from bindings to the view model. Set the limit
/// before the list shows its rows: a view learns whether its list keeps views when it
/// is bound, so one bound before the limit was raised is torn down once more and kept
/// from the next time it is bound.
Comment on lines +42 to +43
**Verdict: solved** on `fix/maui-replaced-view-model-lifecycle` at `b8c839f`, contained in
`Stellar.Maui`, no core change, no per-recycle allocation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants