Repository navigation
Use weak events for GradientBrush gradient stop subscriptions - #38397
wbv-profile wants to merge 2 commits into
Conversation
GradientBrush subscribed to its GradientStops collection and to each GradientStop with plain CLR event handlers, unsubscribing only when the GradientStops property was replaced. A collection or stop that outlives the brush therefore kept the brush alive. Two retention paths are fixed: - The collection subscription now goes through WeakNotifyCollectionChangedProxy, so a long-lived (for example shared or resource-declared) GradientStopCollection no longer roots every transient brush assigned it. - Clear() raises a Reset carrying no OldItems, so the stops it removed kept both their PropertyChanged subscription and their Parent back-reference. The brush now tracks the stops it is subscribed to and detaches them on Reset, and per-stop subscriptions go through WeakNotifyPropertyChangedProxy. This follows the existing pattern in Border and Shape, including the finalizer the proxies require. Invalidation behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 38397Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 38397" |
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
Hey there @@wbv-profile! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
Hey there @wbv-profile! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
The finalizer was only listed in the net PublicAPI.Unshipped.txt, so the RS0016 analyzer would have failed the android, ios, maccatalyst, tizen, windows and netstandard legs. Shape.~Shape() and Label.~Label() are declared across all seven surfaces for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@dotnet-policy-service agree |
|
Closing this alternate implementation in favor of the existing The current patch also has two concrete ownership problems in This addresses real problems still visible on |
Description of Change
GradientBrushsubscribes to itsGradientStopscollection and to eachGradientStopwith plain CLR event handlers, and only unsubscribes when theGradientStopsproperty is replaced. AGradientStopCollection(or an individual stop) that outlives the brush therefore keeps the brush — and everything it references — alive.There are two distinct retention paths:
The collection.
UpdateGradientStopsdidnewCollection.CollectionChanged += OnGradientStopCollectionChanged. A long-lived collection, for example one declared in aResourceDictionaryand shared between brushes, roots every transient brush ever assigned it.The stops, after
Clear().OnGradientStopCollectionChangeddetached handlers only frome.OldItems.ObservableCollection.Clear()raises aReset, which carries noOldItems, so the removed stops kept both theirPropertyChangedsubscription and theirParentback-reference to the brush.This change routes both subscriptions through the existing weak-event primitives in
src/Controls/src/Core/Internals/WeakEventProxy.cs(WeakNotifyCollectionChangedProxy/WeakNotifyPropertyChangedProxy), following the pattern already used byBorderandShape, and adds a finalizer that unsubscribes — as the proxy documentation requires.Because a
Resetreports no old items and arrives after the collection has already been emptied, the brush now tracks the stops it is subscribed to in aDictionary<GradientStop, WeakNotifyPropertyChangedProxy>. That set is the only record of what still needs detaching onClear(), and it also lets a stop that legitimately appears more than once in a collection be detached only when its last occurrence is gone.Behaviour is otherwise unchanged: collection changes and stop property changes still raise
InvalidateGradientBrushRequested, and stops attached to a brush still get theirParentset. Stops removed byClear()now correctly haveParentreset tonull, which previously leaked as a stale parent.Note that
GradientStop.Parentremains a strong reference by design, so the brush that currently owns a set of stops is still reachable from them; this change only stops discarded brushes from being retained.Tests
src/Controls/tests/Core.UnitTests/GradientBrushTests.csadds:SharedEmptyCollectionDoesNotRootBrush— isolates theCollectionChangedpath (empty collection, soParentcannot mask it).SharedPopulatedCollectionDoesNotRootDiscardedBrush— a second brush takes overParent, leaving the first retained only by its subscriptions.RetainedStopDoesNotRootBrushAfterClear— theReset/Clear()path.ClearDetachesStopParentandClearStopsInvalidationFromRemovedStop— behavioural cover for theResetfix.AttachedStopStillRaisesInvalidationandCollectionChangesStillRaiseInvalidation— guard that the weak subscriptions still deliver events.Verified by reverting
GradientBrush.csto its current state and re-running: the five leak/Resettests fail, and the two invalidation guards pass. With the fix all 7 pass, and the fullControls.Core.UnitTestssuite is green (5654 passed, 0 failed, 30 skipped).Issues Fixed
The leak scanner filed the shared-collection retention four separate times; all four describe the same two code paths fixed here.
Fixes #37581
Fixes #38164
Fixes #38179
Fixes #38283