From ccf385e72087f554054f598966a80cd992a2ce9e Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:37:43 +0200 Subject: [PATCH] [leak-fix] Fix IndicatorView.ItemsSource memory leak (Fixes #35775) Replace the non-weak CollectionChanged subscription in ResetItemsSource with a WeakNotifyCollectionChangedProxy, and add a finalizer that unsubscribes, so a shared/long-lived collection no longer roots every IndicatorView it is assigned to. Adds a Controls.Core.UnitTests regression test that fails without this fix (mirrors the existing BindableLayoutController weak-proxy pattern). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Core/IndicatorView/IndicatorView.cs | 16 ++++++++++---- .../net-android/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 1 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 1 + .../net-tizen/PublicAPI.Unshipped.txt | 1 + .../net-windows/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net/PublicAPI.Unshipped.txt | 1 + .../netstandard/PublicAPI.Unshipped.txt | 1 + .../IndicatorViewLayoutTests.cs | 21 +++++++++++++++++++ 9 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/IndicatorView/IndicatorView.cs b/src/Controls/src/Core/IndicatorView/IndicatorView.cs index 961d99bb11d3..363a05abcf62 100644 --- a/src/Controls/src/Core/IndicatorView/IndicatorView.cs +++ b/src/Controls/src/Core/IndicatorView/IndicatorView.cs @@ -60,10 +60,18 @@ public partial class IndicatorView : TemplatedView, ITemplatedIndicatorView static readonly BindableProperty IndicatorLayoutProperty = BindableProperty.Create(nameof(IndicatorLayout), typeof(IBindableLayout), typeof(IndicatorView), null, propertyChanged: TemplateUtilities.OnContentChanged); + readonly WeakNotifyCollectionChangedProxy _collectionChangedProxy = new(); + readonly NotifyCollectionChangedEventHandler _collectionChangedEventHandler; + /// /// Initializes a new instance of the class. /// - public IndicatorView() { } + public IndicatorView() + { + _collectionChangedEventHandler = OnCollectionChanged; + } + + ~IndicatorView() => _collectionChangedProxy.Unsubscribe(); /// /// Gets or sets the shape of the indicators. @@ -237,11 +245,11 @@ static void UpdateIndicatorLayout(IndicatorView indicatorView, object newValue) void ResetItemsSource(IEnumerable oldItemsSource) { - if (oldItemsSource is INotifyCollectionChanged oldCollection) - oldCollection.CollectionChanged -= OnCollectionChanged; + if (oldItemsSource is INotifyCollectionChanged) + _collectionChangedProxy.Unsubscribe(); if (ItemsSource is INotifyCollectionChanged collection) - collection.CollectionChanged += OnCollectionChanged; + _collectionChangedProxy.Subscribe(collection, _collectionChangedEventHandler); OnCollectionChanged(ItemsSource, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index a073ac4961c6..6b9fd9a1a982 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -14,3 +14,4 @@ override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnH ~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 5099477ef08b..d8705ebc2347 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -13,3 +13,4 @@ override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDi ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 5099477ef08b..d8705ebc2347 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -13,3 +13,4 @@ override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDi ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 145d755476e6..b1222d0a2660 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -3,3 +3,4 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 4ea9723de9ff..d0adcac7abda 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -4,3 +4,4 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index 145d755476e6..b1222d0a2660 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -3,3 +3,4 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 145d755476e6..b1222d0a2660 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -3,3 +3,4 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +Microsoft.Maui.Controls.IndicatorView.~IndicatorView() -> void diff --git a/src/Controls/tests/Core.UnitTests/IndicatorViewLayoutTests.cs b/src/Controls/tests/Core.UnitTests/IndicatorViewLayoutTests.cs index f680aa089e91..611d2b6e4d15 100644 --- a/src/Controls/tests/Core.UnitTests/IndicatorViewLayoutTests.cs +++ b/src/Controls/tests/Core.UnitTests/IndicatorViewLayoutTests.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; using Xunit; namespace Microsoft.Maui.Controls.Core.UnitTests @@ -65,5 +68,23 @@ public void IndicatorLayout_ShouldBeRemovedWhenIndicatorTemplateIsNulled() //Assert Assert.Null(indicatorView.IndicatorLayout); } + + [Fact, Category(TestCategory.Memory)] + public async Task IndicatorViewItemsSourceDoesNotLeak() + { + // A long-lived collection - e.g. a ViewModel's items that is also bound to a + // CarouselView - must not keep every IndicatorView it was assigned to alive. + var sharedItemsSource = new ObservableCollection { "a", "b", "c" }; + + WeakReference weakIndicatorView; + { + var indicatorView = new IndicatorView(); + indicatorView.ItemsSource = sharedItemsSource; + weakIndicatorView = new WeakReference(indicatorView); + } + + Assert.False(await weakIndicatorView.WaitForCollect(), "IndicatorView should not be alive!"); + GC.KeepAlive(sharedItemsSource); + } } }