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);
+ }
}
}