From e60e4eb334bcb5fd8c8f39326c58ab03b59a5e60 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:25:21 +0530 Subject: [PATCH 1/4] Fixed FormattedStringDoesNotLeak --- src/Controls/src/Core/FormattedString.cs | 32 ++++++++++-- .../src/Core/Internals/WeakEventProxy.cs | 49 +++++++++++++++++++ .../PublicAPI/net/PublicAPI.Unshipped.txt | 2 + .../Core.UnitTests/FormattedStringTests.cs | 28 +++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/FormattedString.cs b/src/Controls/src/Core/FormattedString.cs index 396005f6cf6b..a40011646474 100644 --- a/src/Controls/src/Core/FormattedString.cs +++ b/src/Controls/src/Core/FormattedString.cs @@ -23,9 +23,25 @@ internal event NotifyCollectionChangedEventHandler SpansCollectionChanged remove => _weakEventManager.RemoveEventHandler(value, nameof(SpansCollectionChanged)); } + // Subscribe to each Span's PropertyChanging/PropertyChanged via weak proxies so that a + // shared or long-lived Span (e.g. one held by a view-model or App.Resources) does not keep + // this FormattedString alive through the event subscriptions. + readonly Dictionary _spanProxies = new(); + PropertyChangedEventHandler _spanPropertyChanged; + PropertyChangingEventHandler _spanPropertyChanging; + /// Initializes a new instance of the FormattedString class. public FormattedString() => _spans.CollectionChanged += OnCollectionChanged; + ~FormattedString() + { + foreach (var proxies in _spanProxies.Values) + { + proxies.Changed?.Unsubscribe(); + proxies.Changing?.Unsubscribe(); + } + } + protected override void OnBindingContextChanged() { base.OnBindingContextChanged(); @@ -53,8 +69,12 @@ void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) if (bo != null) { bo.Parent?.RemoveLogicalChild(bo); - bo.PropertyChanging -= OnItemPropertyChanging; - bo.PropertyChanged -= OnItemPropertyChanged; + if (_spanProxies.TryGetValue(bo, out var proxies)) + { + proxies.Changed?.Unsubscribe(); + proxies.Changing?.Unsubscribe(); + _spanProxies.Remove(bo); + } } } @@ -62,14 +82,18 @@ void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) if (e.NewItems != null) { + _spanPropertyChanged ??= OnItemPropertyChanged; + _spanPropertyChanging ??= OnItemPropertyChanging; + foreach (object item in e.NewItems) { var bo = item as Span; if (bo != null) { this.AddLogicalChild(bo); - bo.PropertyChanging += OnItemPropertyChanging; - bo.PropertyChanged += OnItemPropertyChanged; + _spanProxies[bo] = ( + new WeakNotifyPropertyChangedProxy(bo, _spanPropertyChanged), + new WeakNotifyPropertyChangingProxy(bo, _spanPropertyChanging)); } } diff --git a/src/Controls/src/Core/Internals/WeakEventProxy.cs b/src/Controls/src/Core/Internals/WeakEventProxy.cs index 9a793d096b4c..71cd4a111a5d 100644 --- a/src/Controls/src/Core/Internals/WeakEventProxy.cs +++ b/src/Controls/src/Core/Internals/WeakEventProxy.cs @@ -199,4 +199,53 @@ public override void Unsubscribe() base.Unsubscribe(); } } + + /// + /// A "proxy" class for subscribing to via WeakReference. + /// General usage is to store this in a member variable and call Subscribe()/Unsubscribe() appropriately. + /// Your class should have a finalizer that calls Unsubscribe() to prevent WeakNotifyPropertyChangingProxy objects from leaking. + /// + class WeakNotifyPropertyChangingProxy : WeakEventProxy + { + public WeakNotifyPropertyChangingProxy() { } + + public WeakNotifyPropertyChangingProxy(BindableObject source, PropertyChangingEventHandler handler) + { + Subscribe(source, handler); + } + + void OnPropertyChanging(object? sender, PropertyChangingEventArgs e) + { + if (TryGetHandler(out var handler)) + { + handler(sender, e); + } + else + { + Unsubscribe(); + } + } + + public override void Subscribe(BindableObject source, PropertyChangingEventHandler handler) + { + if (TryGetSource(out var s)) + { + s.PropertyChanging -= OnPropertyChanging; + } + + source.PropertyChanging += OnPropertyChanging; + + base.Subscribe(source, handler); + } + + public override void Unsubscribe() + { + if (TryGetSource(out var s)) + { + s.PropertyChanging -= OnPropertyChanging; + } + + base.Unsubscribe(); + } + } } diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index 5dd193844cfa..d1e2baa5e431 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -5,3 +5,5 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void +Microsoft.Maui.Controls.Label.~Label() -> void diff --git a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs index 83157fc43e3d..b9be2768e381 100644 --- a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs +++ b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Threading.Tasks; using Xunit; namespace Microsoft.Maui.Controls.Core.UnitTests @@ -100,5 +101,32 @@ public void ImplicitStringConversionNull() Assert.NotNull(fs.Spans[0]); Assert.Equal(fs.Spans[0].Text, original); } + + [Fact, Category(TestCategory.Memory)] + public async Task FormattedStringDoesNotLeak() + { + // Long-lived span, like one shared from a view-model or App.Resources. + // Adding it to a FormattedString subscribes FormattedString to the + // span's PropertyChanged/PropertyChanging events and makes FormattedString + // the span's logical parent. If those references aren't weak, the shared + // span keeps every FormattedString it was added to alive. + var span = new Span { Text = "Hello" }; + + WeakReference CreateReference() + { + var fs = new FormattedString(); + fs.Spans.Add(span); + return new(fs); + } + + WeakReference reference = CreateReference(); + + await TestHelpers.Collect(); + + Assert.False(await reference.WaitForCollect(), "FormattedString should not be alive!"); + + // Ensure the shared Span isn't collected during the test + GC.KeepAlive(span); + } } } \ No newline at end of file From 886c885201aa2186e0e6c2317ebc08e975a64462 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:48:43 +0530 Subject: [PATCH 2/4] Updated Unshipped.txt --- .../src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt | 1 + src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt | 2 ++ .../src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | 2 ++ .../src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | 1 + .../src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt | 1 + .../src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt | 1 + 6 files changed, 8 insertions(+) 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 dddd92d96824..4f3d6ecd4ee9 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -22,3 +22,4 @@ override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnH ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 25c1b293624b..4c897437834c 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -15,3 +15,5 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 25c1b293624b..4c897437834c 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -15,3 +15,5 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 5dd193844cfa..ee2fd9141070 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -5,3 +5,4 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 e24d5a76d14f..7328bb2aff10 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -7,3 +7,4 @@ override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items.CarouselViewHandler.UpdateEmptyViewVisibility() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 5dd193844cfa..ee2fd9141070 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -5,3 +5,4 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void From efd1d14b8c2fca79244ecb19bf85ab46de0b03ab Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:25:51 +0530 Subject: [PATCH 3/4] Addressed AI summary concern --- src/Controls/src/Core/FormattedString.cs | 122 ++++++++++++++---- .../src/Core/Internals/WeakEventProxy.cs | 49 ------- .../net-android/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 2 - .../net-maccatalyst/PublicAPI.Unshipped.txt | 2 - .../net-tizen/PublicAPI.Unshipped.txt | 1 - .../net-windows/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net/PublicAPI.Unshipped.txt | 2 - .../netstandard/PublicAPI.Unshipped.txt | 1 - .../Core.UnitTests/FormattedStringTests.cs | 41 ++++++ 10 files changed, 137 insertions(+), 85 deletions(-) diff --git a/src/Controls/src/Core/FormattedString.cs b/src/Controls/src/Core/FormattedString.cs index a40011646474..4870ee4ac169 100644 --- a/src/Controls/src/Core/FormattedString.cs +++ b/src/Controls/src/Core/FormattedString.cs @@ -23,23 +23,16 @@ internal event NotifyCollectionChangedEventHandler SpansCollectionChanged remove => _weakEventManager.RemoveEventHandler(value, nameof(SpansCollectionChanged)); } - // Subscribe to each Span's PropertyChanging/PropertyChanged via weak proxies so that a - // shared or long-lived Span (e.g. one held by a view-model or App.Resources) does not keep - // this FormattedString alive through the event subscriptions. - readonly Dictionary _spanProxies = new(); - PropertyChangedEventHandler _spanPropertyChanged; - PropertyChangingEventHandler _spanPropertyChanging; + // Subscribe to each Span's PropertyChanging/PropertyChanged via per-occurrence weak + // subscription tokens so that a shared or long-lived Span (e.g. one held by a view-model or + // App.Resources) does not keep this FormattedString alive through the event subscriptions. + readonly SpanSubscriptions _spanSubscriptions; /// Initializes a new instance of the FormattedString class. - public FormattedString() => _spans.CollectionChanged += OnCollectionChanged; - - ~FormattedString() + public FormattedString() { - foreach (var proxies in _spanProxies.Values) - { - proxies.Changed?.Unsubscribe(); - proxies.Changing?.Unsubscribe(); - } + _spanSubscriptions = new SpanSubscriptions(this); + _spans.CollectionChanged += OnCollectionChanged; } protected override void OnBindingContextChanged() @@ -69,12 +62,7 @@ void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) if (bo != null) { bo.Parent?.RemoveLogicalChild(bo); - if (_spanProxies.TryGetValue(bo, out var proxies)) - { - proxies.Changed?.Unsubscribe(); - proxies.Changing?.Unsubscribe(); - _spanProxies.Remove(bo); - } + _spanSubscriptions.Remove(bo); } } @@ -82,18 +70,13 @@ void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) if (e.NewItems != null) { - _spanPropertyChanged ??= OnItemPropertyChanged; - _spanPropertyChanging ??= OnItemPropertyChanging; - foreach (object item in e.NewItems) { var bo = item as Span; if (bo != null) { this.AddLogicalChild(bo); - _spanProxies[bo] = ( - new WeakNotifyPropertyChangedProxy(bo, _spanPropertyChanged), - new WeakNotifyPropertyChangingProxy(bo, _spanPropertyChanging)); + _spanSubscriptions.Add(bo); } } @@ -107,6 +90,93 @@ void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) void OnItemPropertyChanging(object sender, PropertyChangingEventArgs e) => OnPropertyChanging(nameof(Spans)); + sealed class SpanSubscriptions + { + readonly WeakReference _owner; + readonly List _subscriptions = new(); + + public SpanSubscriptions(FormattedString owner) => _owner = new(owner); + + ~SpanSubscriptions() => Clear(); + + public void Add(Span span) => _subscriptions.Add(new SpanSubscription(_owner, span)); + + public void Remove(Span span) + { + for (int i = 0; i < _subscriptions.Count; i++) + { + if (_subscriptions[i].Span == span) + { + _subscriptions[i].Unsubscribe(); + _subscriptions.RemoveAt(i); + return; + } + } + } + + void Clear() + { + foreach (var subscription in _subscriptions) + { + subscription.Unsubscribe(); + } + + _subscriptions.Clear(); + } + } + + sealed class SpanSubscription + { + readonly WeakReference _owner; + Span _span; + + public SpanSubscription(WeakReference owner, Span span) + { + _owner = owner; + _span = span; + _span.PropertyChanging += OnPropertyChanging; + _span.PropertyChanged += OnPropertyChanged; + } + + public Span Span => _span; + + public void Unsubscribe() + { + if (_span is null) + { + return; + } + + _span.PropertyChanging -= OnPropertyChanging; + _span.PropertyChanged -= OnPropertyChanged; + _span = null; + } + + void OnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_owner.TryGetTarget(out var owner)) + { + owner.OnItemPropertyChanged(sender, e); + } + else + { + Unsubscribe(); + } + } + + void OnPropertyChanging(object sender, PropertyChangingEventArgs e) + { + if (_owner.TryGetTarget(out var owner)) + { + owner.OnItemPropertyChanging(sender, e); + } + else + { + Unsubscribe(); + } + } + } + class SpanCollection : ObservableCollection { protected override void InsertItem(int index, Span item) => base.InsertItem(index, item ?? throw new ArgumentNullException(nameof(item))); diff --git a/src/Controls/src/Core/Internals/WeakEventProxy.cs b/src/Controls/src/Core/Internals/WeakEventProxy.cs index 71cd4a111a5d..9a793d096b4c 100644 --- a/src/Controls/src/Core/Internals/WeakEventProxy.cs +++ b/src/Controls/src/Core/Internals/WeakEventProxy.cs @@ -199,53 +199,4 @@ public override void Unsubscribe() base.Unsubscribe(); } } - - /// - /// A "proxy" class for subscribing to via WeakReference. - /// General usage is to store this in a member variable and call Subscribe()/Unsubscribe() appropriately. - /// Your class should have a finalizer that calls Unsubscribe() to prevent WeakNotifyPropertyChangingProxy objects from leaking. - /// - class WeakNotifyPropertyChangingProxy : WeakEventProxy - { - public WeakNotifyPropertyChangingProxy() { } - - public WeakNotifyPropertyChangingProxy(BindableObject source, PropertyChangingEventHandler handler) - { - Subscribe(source, handler); - } - - void OnPropertyChanging(object? sender, PropertyChangingEventArgs e) - { - if (TryGetHandler(out var handler)) - { - handler(sender, e); - } - else - { - Unsubscribe(); - } - } - - public override void Subscribe(BindableObject source, PropertyChangingEventHandler handler) - { - if (TryGetSource(out var s)) - { - s.PropertyChanging -= OnPropertyChanging; - } - - source.PropertyChanging += OnPropertyChanging; - - base.Subscribe(source, handler); - } - - public override void Unsubscribe() - { - if (TryGetSource(out var s)) - { - s.PropertyChanging -= OnPropertyChanging; - } - - base.Unsubscribe(); - } - } } 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 4f3d6ecd4ee9..dddd92d96824 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -22,4 +22,3 @@ override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnH ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 4c897437834c..25c1b293624b 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -15,5 +15,3 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 4c897437834c..25c1b293624b 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -15,5 +15,3 @@ override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? property override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 ee2fd9141070..5dd193844cfa 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -5,4 +5,3 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> 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 7328bb2aff10..e24d5a76d14f 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -7,4 +7,3 @@ override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items.CarouselViewHandler.UpdateEmptyViewVisibility() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index d1e2baa5e431..5dd193844cfa 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -5,5 +5,3 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void -Microsoft.Maui.Controls.Label.~Label() -> void diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index ee2fd9141070..5dd193844cfa 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -5,4 +5,3 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void -Microsoft.Maui.Controls.FormattedString.~FormattedString() -> void diff --git a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs index b9be2768e381..d0101b1acceb 100644 --- a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs +++ b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs @@ -62,6 +62,47 @@ public void SpanChangesUnsubscribes() Assert.False(spansChanged); } + [Fact] + public void DuplicateSpanChangesUnsubscribes() + { + var span = new Span(); + var fs = new FormattedString(); + fs.Spans.Add(span); + fs.Spans.Add(span); + fs.Spans.Remove(span); + fs.Spans.Remove(span); + + bool spansChanged = false; + fs.PropertyChanged += (s, e) => + { + if (e.PropertyName == "Spans") + spansChanged = true; + }; + + span.Text = "New text"; + + Assert.False(spansChanged); + } + + [Fact] + public void SpanChangingTriggersSpansPropertyChanging() + { + var span = new Span { Text = "Original text" }; + var fs = new FormattedString(); + fs.Spans.Add(span); + + bool spansChanging = false; + fs.PropertyChanging += (s, e) => + { + if (e.PropertyName == "Spans") + spansChanging = true; + }; + + span.Text = "New text"; + + Assert.True(spansChanging); + } + [Fact] public void AddingSpanTriggersSpansPropertyChange() { From a76e6ed8fb6293152a0dfa9f042148cdcfb4ac4b Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:14:36 +0530 Subject: [PATCH 4/4] Added additional test case --- src/Controls/src/Core/FormattedString.cs | 5 +++ .../Core.UnitTests/FormattedStringTests.cs | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/Controls/src/Core/FormattedString.cs b/src/Controls/src/Core/FormattedString.cs index 4870ee4ac169..621ea05fcfa3 100644 --- a/src/Controls/src/Core/FormattedString.cs +++ b/src/Controls/src/Core/FormattedString.cs @@ -127,6 +127,11 @@ void Clear() sealed class SpanSubscription { + // The Span's event delegate holds a strong reference to this SpanSubscription, so the + // instance is kept alive until either the Span fires an event (which triggers self-cleanup + // via the weak-owner check in OnPropertyChanged/OnPropertyChanging) or the SpanSubscriptions + // finalizer runs. This is an accepted trade-off of weak-event cleanup: only these small + // tokens may linger briefly, while the owning FormattedString is free to be collected. readonly WeakReference _owner; Span _span; diff --git a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs index d0101b1acceb..433eda4880a4 100644 --- a/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs +++ b/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs @@ -84,6 +84,47 @@ public void DuplicateSpanChangesUnsubscribes() Assert.False(spansChanged); } + [Fact] + public void DuplicateSpanKeepsOneSubscriptionAfterSingleRemove() + { + var span = new Span(); + var fs = new FormattedString(); + fs.Spans.Add(span); + fs.Spans.Add(span); + fs.Spans.Remove(span); // removes one occurrence only + + bool spansChanged = false; + fs.PropertyChanged += (s, e) => + { + if (e.PropertyName == "Spans") + spansChanged = true; + }; + + span.Text = "New text"; + + Assert.True(spansChanged); // second subscription still active + } + + [Fact] + public void SpanChangingUnsubscribesAfterRemoval() + { + var span = new Span { Text = "Original" }; + var fs = new FormattedString(); + fs.Spans.Add(span); + fs.Spans.Remove(span); + + bool spansChanging = false; + fs.PropertyChanging += (s, e) => + { + if (e.PropertyName == "Spans") + spansChanging = true; + }; + + span.Text = "New text"; + + Assert.False(spansChanging); + } + [Fact] public void SpanChangingTriggersSpansPropertyChanging() {