[Android] Ensure disconnected ItemsViewHandler doesn't hold onto the items source#24610
Conversation
|
Hey there @filipnavara! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
Apparently |
Hmmm, maybe for now we just restore |
Would it be okay to add implementation of |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
The test failure looks relevant. I don't see why it happens though, and I will need to reproduce it locally. |
rmarinho
left a comment
There was a problem hiding this comment.
Hey @filipnavara since #24699 was merged, can we rebase this for the other platforms?
|
Azure Pipelines successfully started running 3 pipeline(s). |
📋 UI Test Analysis for PR #24610Finding: Device Tests More Appropriate Than UI TestsAfter attempting to create UI tests for this PR, I've determined that UI tests are not suitable for validating this fix. Here's why: Nature of the Bug
Why UI Tests Won't Work
Existing Device Test Coverage ✅The PR already includes a proper Device Test that validates this fix:
RecommendationNo additional UI tests needed - the existing Device Test properly validates the fix by checking internal state. UI tests would provide false confidence since they can't actually verify the bug is fixed. Analysis performed by PR Agent |
🤖 PR Agent Review📊 Expand Full ReviewStatus: ✅ APPROVE
🔍 Phase 1: Pre-Flight — Context & Validation📝 Review Session — Fix unregistering CollectionChanged in MarshalingObservableCollection ·
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #24610 | Use WeakNotifyCollectionChangedProxy in MarshalingObservableCollection, add internal Dispose() method, add disposeItemsSource parameter to ObservableItemsSource, call Dispose in factory for internal collections | ✅ PASS (Gate) | 4 files | Original PR approach - proper cleanup chain |
Alternative Analysis (Theoretical - Device Tests require Helix infrastructure):
| Alternative | Viability | Why Not Chosen |
|---|---|---|
| Implement IDisposable on MarshalingObservableCollection | ❌ | Breaking API change - discussed in PR comments |
| Dispose at handler level | ❌ | Requires changes to each handler type, less maintainable |
| Use finalizer pattern | ❌ | Non-deterministic, unreliable for event cleanup |
| Different WeakReference approach | ❌ | WeakNotifyCollectionChangedProxy already exists in codebase |
Note: try-fix skill not invoked because:
- Device Tests require Helix infrastructure, not locally runnable
- Alternative approaches were already discussed in PR and rejected for valid reasons
- PR's approach is optimal - uses existing patterns, minimal invasive, proper disposal chain
Exhausted: Yes (alternatives analyzed, no viable improvements over PR's fix)
Selected Fix: PR's fix - Uses existing WeakNotifyCollectionChangedProxy pattern, minimal changes, proper disposal chain from factory to collection
📋 Phase 5: Report — Final Recommendation
📝 Review Session — Fix unregistering CollectionChanged in MarshalingObservableCollection · 436e22d
Status: ✅ COMPLETE
Final Recommendation: ✅ APPROVE
Key Findings
- Root Cause Identified:
MarshalingObservableCollectiondirectly subscribed toCollectionChangedin constructor but never unsubscribed, causing event handlers to keep UI tree alive after disposal - Fix Validated: PR uses
WeakNotifyCollectionChangedProxypattern (consistent with codebase) + internalDispose()method + factory disposal chain - Test Coverage Verified: Device Test
DisconnectedCarouselViewDoesNotHookCollectionViewChangedvalidates the fix by checkingIsCollectionChangedEventEmptyafter disconnect - No API Breaking Changes: Uses
internal void Dispose()instead of publicIDisposableinterface
Summary:
This PR correctly fixes a memory leak in Android's ItemsView controls where MarshalingObservableCollection never unsubscribed from CollectionChanged events, causing crashes when handlers were disconnected but collection changes still occurred.
Technical Insights:
- Factory creates
MarshalingObservableCollectioninternally → factory owns lifecycle → factory disposes - User-provided collections are NOT disposed (correct ownership semantics)
WeakNotifyCollectionChangedProxyprevents strong reference retention
Alternatives Considered:
- Public
IDisposableinterface → Rejected (breaking API change, discussed in PR comments) - Handler-level disposal → Rejected (less maintainable, affects each handler type)
- Finalizer pattern → Rejected (non-deterministic for event cleanup)
Recommendation:
The PR's approach is optimal - minimal invasive changes, consistent with codebase patterns, proper ownership semantics.
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #24610 | Use WeakNotifyCollectionChangedProxy in MarshalingObservableCollection; dispose proxy in ObservableItemsSource.Dispose() with disposeItemsSource flag | ⏳ PENDING (Gate) | 4 files | Original PR; removes #if !ANDROID guard from test |
🚦 Gate — Test Verification
Test Verification Gate — PR #24610
Status: ✅ GATE PASSED
Summary
| Check | Expected | Actual | Result |
|---|---|---|---|
| Tests WITHOUT fix | FAIL | FAIL | ✅ |
| Tests WITH fix | PASS | PASS | ✅ |
✅ Final Verdict: VERIFICATION PASSED
The tests correctly detect the issue:
- ✅
DisconnectedCarouselViewDoesNotHookCollectionViewChangedFAILS without the fix (as expected — bug is present) - ✅
DisconnectedCarouselViewDoesNotHookCollectionViewChangedPASSES with the fix (as expected — bug is fixed)
Conclusion: The test properly validates the fix and catches the bug when it's present.
Configuration
| Property | Value |
|---|---|
| PR | #24610 |
| Issue | #24304 |
| Platform | Android (emulator-5554, API 30) |
| Test project | Controls.DeviceTests (src/Controls/tests/DeviceTests/) |
| Test method | CarouselViewTests.DisconnectedCarouselViewDoesNotHookCollectionViewChanged |
| Base branch | main |
| Merge base | 720a9d4a93 |
Fix Files Reverted/Restored
| File | Action |
|---|---|
src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ItemsSourceFactory.cs |
Reverted → Restored |
src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ObservableItemsSource.cs |
Reverted → Restored |
src/Controls/src/Core/Items/MarshalingObservableCollection.cs |
Reverted → Restored |
Note: The test file CarouselViewTests.cs was NOT reverted — the #if !ANDROID guard removal was kept in both runs (the test needs to exist on Android in both phases).
Test Results Details
Phase 1: WITHOUT Fix
Expected: Test should FAIL (bug is present)
Actual: ❌ FAILED — as expected
03-25 14:52:52.804 I DOTNET : Test name: DisconnectedCarouselViewDoesNotHookCollectionViewChanged
03-25 14:52:52.822 I DOTNET : Exception stack traces: at Microsoft.Maui.DeviceTests.CarouselViewTests.DisconnectedCarouselViewDoesNotHookCollectionViewChanged()
03-25 14:52:52.822 I DOTNET : [FAIL] DisconnectedCarouselViewDoesNotHookCollectionViewChanged
03-25 14:52:52.822 I DOTNET : Actual: False
Failure reason: Assert.True(data.IsCollectionChangedEventEmpty) failed with Actual: False. Without the fix, the MarshalingObservableCollection directly subscribes to the original collection's CollectionChanged event and does not unsubscribe when the handler is disconnected. The subscription remains active, proving the bug is present.
Phase 2: WITH Fix
Expected: Test should PASS (bug is fixed)
Actual: ✅ PASSED — as expected
03-25 15:08:07.261 I DOTNET : Test collection for Microsoft.Maui.DeviceTests.CarouselViewTests
03-25 15:08:07.855 I DOTNET : [PASS] DisconnectedCarouselViewDoesNotHookCollectionViewChanged
03-25 15:08:11.869 I DOTNET : Microsoft.Maui.DeviceTests.CarouselViewTests 4.5884244 ms
Fix explanation: The fix uses WeakNotifyCollectionChangedProxy in MarshalingObservableCollection instead of a direct event subscription, and adds a Dispose() method that calls _proxy.Unsubscribe(). The ObservableItemsSource is updated to call Dispose() on the MarshalingObservableCollection when its own Dispose() is called (with disposeItemsSource: true). ItemsSourceFactory passes disposeItemsSource: true to ensure cleanup happens on disconnect.
Log Files
verify-tests-fail/build-without-fix.log— Build log (without fix)verify-tests-fail/run-without-fix.log— xharness run log (without fix)verify-tests-fail/without-fix/adb-logcat-com.microsoft.maui.controls.devicetests-default.log— Device logcat (without fix)verify-tests-fail/build-with-fix.log— Build log (with fix)verify-tests-fail/run-with-fix.log— xharness run log (with fix, includes CarouselView test results)
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #24610 | WeakNotifyCollectionChangedProxy in MarshalingObservableCollection + dispose chain through ObservableItemsSource | ✅ PASSED (Gate) | 4 files | Original PR |
| 1 | claude-opus-4.6 | Direct -= unsubscribe in MarshalingObservableCollection.Dispose() + call from ObservableItemsSource.Dispose() |
✅ PASS | 2 files | Simpler than PR (no WeakProxy, no disposeItemsSource flag) |
| 2 | claude-sonnet-4.6 | Subclass ownership pattern — MarshaledObservableItemsSource in ItemsSourceFactory owns and disposes MarshalingObservableCollection |
✅ PASS | 2 files | Clean ownership model; no changes to ObservableItemsSource |
| 3 | gpt-5.3-codex | Demand-driven subscription in MarshalingObservableCollection: subscribe when first listener added, unsubscribe when last listener removed | ✅ PASS | 1 file | Self-contained fix; no external dispose needed |
| 4 | gemini-3-pro-preview | Disposal callback injection — onDispose: Action parameter in ObservableItemsSource; MarshalingObservableCollection implements IDisposable publicly |
✅ PASS | 3 files | Clean delegate pattern; public IDisposable on MarshalingObservableCollection |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 2 | No | All approaches covered |
| claude-sonnet-4.6 | 2 | Yes | Merge MOC responsibilities into OIS subclass — single subscriber chain |
| gpt-5.3-codex | 2 | Yes | Subscription revoker/token pattern — returns IDisposable revoker from CollectionChanged add |
| gemini-3-pro-preview | 2 | Yes | Passive MOC (no source subscription); OIS subscribes directly to source and handles marshaling |
| 5 | gemini-3-pro-preview | Explicit Watch()/Unwatch() methods in MarshalingObservableCollection with autoSubscribe param; OIS calls Watch/Unwatch in constructor/Dispose | ✅ PASS | 3 files | Explicit lifecycle control with backward compat |
| 6 | gpt-5.3-codex | Force MapItemsSource(null) in Android DisconnectHandler to trigger normal teardown path | ❌ FAIL | 1 file | Fails: PlatformView is null when DisconnectHandler fires |
Round 3 Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 3 | No | Space exhausted |
| claude-sonnet-4.6 | 3 | Yes | IDisposable subscription token (variant of Attempt 4) |
| gpt-5.3-codex | 3 | Yes | MapItemsSource(null) in DisconnectHandler — tried as Attempt 6, ❌ FAILED |
| gemini-3-pro-preview | 3 | Yes | External Mediator Pattern (complex variant) |
Exhausted: Yes (max 3 rounds reached; remaining ideas are variants of existing approaches or already disproven)
Selected Fix: PR #24610's fix — WeakNotifyCollectionChangedProxy + disposeItemsSource flag
Reason for selecting PR's fix:
- Defense-in-depth: Even if
Dispose()is somehow skipped, theWeakNotifyCollectionChangedProxymeans the subscription is weak — it won't keep the zombie UI tree alive on its own. The 5 passing alternatives all rely onDispose()being called; the PR's fix works even if it isn't. - Codebase consistency:
WeakNotifyCollectionChangedProxyis already used inObservableItemsSourceitself, making the PR's approach stylistically consistent. - Gate verified: Test FAILS without fix, PASSES with fix — empirically confirmed on Android emulator.
- Attempt 1 is simplest: If reviewers prefer fewer lines, Attempt 1 (2 files, direct
-=unsubscribe) is a viable, simpler alternative with the same test pass — though it lacks the weak-reference safety net.
📋 Report — Final Recommendation
✅ Final Recommendation: APPROVE
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Issue #24304 + PR #24610 context gathered |
| Gate | ✅ PASSED | Android (emulator API 30) — test FAILS without fix, PASSES with fix |
| Try-Fix | ✅ COMPLETE | 6 attempts (5 passing, 1 failing), 3 cross-pollination rounds |
| Report | ✅ COMPLETE | PR's fix selected |
Summary
PR #24610 correctly fixes a memory-leak/zombie-UI-tree bug where CarouselView/CollectionView bound to an ObservableCollection never disconnected from ItemsSource.CollectionChanged when DisconnectHandler() was called. If the collection outlived the page, future changes would crash the app with NullReferenceException (issue #24304).
The fix was empirically verified on Android: the new device test DisconnectedCarouselViewDoesNotHookCollectionViewChanged fails without the PR's changes and passes with them. Five independent alternative fixes were explored and all confirmed the approach is sound; the PR's fix was selected as the best.
Root Cause
On Android, ItemsSourceFactory wraps IList + INotifyCollectionChanged in a MarshalingObservableCollection, which subscribed directly (+=) to the source collection's CollectionChanged in its constructor. This strong reference was never removed:
- Page navigated away →
DisconnectHandler()called on tree MarshalingObservableCollectionstill held a strongCollectionChangedsubscription to the user'sObservableCollection- The user's collection (long-lived) kept
MarshalingObservableCollection→ObservableItemsSource→ItemsViewAdapter→RecyclerView(and all its cells) alive - Future mutations to the user's collection triggered
CollectionChangedon the now-partially-disposed UI tree → crash
Fix Quality
PR's approach is sound and has defense-in-depth:
-
MarshalingObservableCollection— replaces directCollectionChanged += InternalCollectionChangedwith aWeakNotifyCollectionChangedProxy. This means even ifDispose()is never called (e.g., accidental GC cycle), the weak reference won't hold the zombie tree alive. Addsinternal Dispose()that calls_proxy.Unsubscribe(). -
ObservableItemsSource— addsdisposeItemsSourceconstructor parameter (defaultfalse) and, when true, callsDispose()on aMarshalingObservableCollectionduring its ownDispose(). -
ItemsSourceFactory— passesdisposeItemsSource: truewhen creatingObservableItemsSourcewith aMarshalingObservableCollection(i.e., the factory created the wrapper and owns it). -
CarouselViewTests.cs— removes#if !ANDROIDguard, enabling the existing test on Android.
Why PR's fix is better than alternatives:
| Approach | Files | Robust if Dispose() skipped? | Notes |
|---|---|---|---|
| PR (WeakProxy + disposeItemsSource) | 4 | ✅ Yes (WeakProxy) | Defense-in-depth |
| Attempt 1 (Direct -=) | 2 | ❌ No | Simpler but no safety net |
| Attempt 2 (Subclass ownership) | 2 | ❌ No | Clean OOP design |
| Attempt 3 (Demand-driven) | 1 | ✅ Yes (auto-unsubscribes) | Elegant but changes MOC semantics |
| Attempt 4 (Callback injection) | 3 | ❌ No | Clean delegate pattern |
| Attempt 5 (Watch/Unwatch) | 3 | ❌ No | Explicit API, backward compat |
| Attempt 6 (Handler null remap) | 1 | — | ❌ FAILS: PlatformView null at disconnect |
The PR's WeakNotifyCollectionChangedProxy is already established in ObservableItemsSource (used for its own subscription), so this change is stylistically consistent with the codebase.
Observations / Minor Notes
-
MarshalingObservableCollection.Dispose()isinternal, notIDisposable. This is intentional — the class ispublicand making it publicIDisposablewould change its API surface. Theinternalmethod is acceptable since the caller (ObservableItemsSource) is internal too. -
PR label says
platform/iosbut the code changes are Android-specific (plus the cross-platformMarshalingObservableCollection). The issue Crash on NullReferenceException with measurement cells in CollectionView #24304 originally reported the crash on iOS, but the root cause also affects Android. The PR title correctly says[Android]. This is not a blocker — just worth noting the label may be misleading. -
No iOS changes — The iOS items implementation (
Items2/iOS/) usesObservableItemsSourcedifferently. The fix ensures Android is covered; iOS coverage should be tracked separately if needed.
Selected Fix: PR's fix ✅
Verdict: The PR correctly identifies the root cause, implements a robust fix with defense-in-depth via WeakNotifyCollectionChangedProxy, and the test properly validates the fix. Multi-model exploration (6 independent attempts) confirms the approach is sound. Recommend APPROVE.
…items source (#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in #24304. ### Issues Fixed Fixes #24304
…items source (dotnet#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in dotnet#24304. ### Issues Fixed Fixes dotnet#24304
…items source (#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in #24304. ### Issues Fixed Fixes #24304
…h Candidate (#35028) <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Issue details: PR #24610 switched collection change wiring to a weak event proxy to fix a memory leak, but the callback delegate was not strongly retained. As a result, GC could collect the delegate, so CollectionChanged notifications stopped reaching the Android items pipeline, which caused the CarouselView and CollectionView test failures. ### Description of Change <!-- Enter description of the fix in this section --> In MarshalingObservableCollection.cs, added a strong field to hold the NotifyCollectionChangedEventHandler delegate. In the constructor, assigned that field once and passed the stored delegate to WeakNotifyCollectionChangedProxy instead of passing a temporary method-group delegate. Kept existing dispose/unsubscribe behavior so the leak fix remains intact while notifications continue to fire correctly. ### Failure test cases **CarouselView tests fails** * VerifyCarouselViewWithKeepItemInView * VerifyCarouselViewWithKeepItemInViewAndPreviousPosition * VerifyCarouselViewWithKeepItemInViewAndCurrentPosition * VerifyCarouselViewWithCurrentPosition * VerifyCarouselViewWithKeepLastItemInViewAndCurrentPosition * AddItemsToCarouselViewWorks **CollectionView test fails** * VerifyModelItemsObservableCollectionWhenAddIndexAtItems * VerifyFlowDirectionRTLAndKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyFlowDirectionLTRAndKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyKeepLastItemInViewWithObservableList * VerifyFlowDirectionLTRAndKeepLastItemInViewWithObservableListWhenHorizontalList * VerifyFlowDirectionRTLAndKeepLastItemInViewWithObservableListWhenHorizontalList * VerifyFlowDirectionRTLAndKee **Device Test fails** * CollectionViewCanSizeToContent
…items source (dotnet#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in dotnet#24304. ### Issues Fixed Fixes dotnet#24304
…items source (#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in #24304. ### Issues Fixed Fixes #24304
…h Candidate (#35028) <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Issue details: PR #24610 switched collection change wiring to a weak event proxy to fix a memory leak, but the callback delegate was not strongly retained. As a result, GC could collect the delegate, so CollectionChanged notifications stopped reaching the Android items pipeline, which caused the CarouselView and CollectionView test failures. ### Description of Change <!-- Enter description of the fix in this section --> In MarshalingObservableCollection.cs, added a strong field to hold the NotifyCollectionChangedEventHandler delegate. In the constructor, assigned that field once and passed the stored delegate to WeakNotifyCollectionChangedProxy instead of passing a temporary method-group delegate. Kept existing dispose/unsubscribe behavior so the leak fix remains intact while notifications continue to fire correctly. ### Failure test cases **CarouselView tests fails** * VerifyCarouselViewWithKeepItemInView * VerifyCarouselViewWithKeepItemInViewAndPreviousPosition * VerifyCarouselViewWithKeepItemInViewAndCurrentPosition * VerifyCarouselViewWithCurrentPosition * VerifyCarouselViewWithKeepLastItemInViewAndCurrentPosition * AddItemsToCarouselViewWorks **CollectionView test fails** * VerifyModelItemsObservableCollectionWhenAddIndexAtItems * VerifyFlowDirectionRTLAndKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyFlowDirectionLTRAndKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyKeepLastItemInViewWithObservableListWhenVerticalGrid * VerifyKeepLastItemInViewWithObservableList * VerifyFlowDirectionLTRAndKeepLastItemInViewWithObservableListWhenHorizontalList * VerifyFlowDirectionRTLAndKeepLastItemInViewWithObservableListWhenHorizontalList * VerifyFlowDirectionRTLAndKee **Device Test fails** * CollectionViewCanSizeToContent
…items source (#24610) ### Description of Change Any control derived from `ItemsView` (`CarouselView`, `CollectionView`) never disconnected from the `ItemsSource.CollectionChanged` callback when `DisconnectHandler()` is called. If the lifetime of the item source outlives the controls it can result in the `CollectionChanged` callback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes with `NullReferenceException` as seen in #24304. ### Issues Fixed Fixes #24304
Description of Change
Any control derived from
ItemsView(CarouselView,CollectionView) never disconnected from theItemsSource.CollectionChangedcallback whenDisconnectHandler()is called. If the lifetime of the item source outlives the controls it can result in theCollectionChangedcallback keeping part of the UI tree alive in a half-zombie state. Any change then calls the callback and it may cause crashes withNullReferenceExceptionas seen in #24304.Issues Fixed
Fixes #24304