Skip to content

Use weak events for GradientBrush gradient stop subscriptions - #38397

Closed
wbv-profile wants to merge 2 commits into
dotnet:mainfrom
wbv-profile:fix/weak-events-gradientbrush
Closed

wbv-profile wants to merge 2 commits into
dotnet:mainfrom
wbv-profile:fix/weak-events-gradientbrush

Conversation

@wbv-profile

Copy link
Copy Markdown

Description of Change

GradientBrush subscribes to its GradientStops collection and to each GradientStop with plain CLR event handlers, and only unsubscribes when the GradientStops property is replaced. A GradientStopCollection (or an individual stop) that outlives the brush therefore keeps the brush — and everything it references — alive.

There are two distinct retention paths:

  1. The collection. UpdateGradientStops did newCollection.CollectionChanged += OnGradientStopCollectionChanged. A long-lived collection, for example one declared in a ResourceDictionary and shared between brushes, roots every transient brush ever assigned it.

  2. The stops, after Clear(). OnGradientStopCollectionChanged detached handlers only from e.OldItems. ObservableCollection.Clear() raises a Reset, which carries no OldItems, so the removed stops kept both their PropertyChanged subscription and their Parent back-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 by Border and Shape, and adds a finalizer that unsubscribes — as the proxy documentation requires.

Because a Reset reports no old items and arrives after the collection has already been emptied, the brush now tracks the stops it is subscribed to in a Dictionary<GradientStop, WeakNotifyPropertyChangedProxy>. That set is the only record of what still needs detaching on Clear(), 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 their Parent set. Stops removed by Clear() now correctly have Parent reset to null, which previously leaked as a stale parent.

Note that GradientStop.Parent remains 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.cs adds:

  • SharedEmptyCollectionDoesNotRootBrush — isolates the CollectionChanged path (empty collection, so Parent cannot mask it).
  • SharedPopulatedCollectionDoesNotRootDiscardedBrush — a second brush takes over Parent, leaving the first retained only by its subscriptions.
  • RetainedStopDoesNotRootBrushAfterClear — the Reset/Clear() path.
  • ClearDetachesStopParent and ClearStopsInvalidationFromRemovedStop — behavioural cover for the Reset fix.
  • AttachedStopStillRaisesInvalidation and CollectionChangesStillRaiseInvalidation — guard that the weak subscriptions still deliver events.

Verified by reverting GradientBrush.cs to its current state and re-running: the five leak/Reset tests fail, and the two invalidation guards pass. With the fix all 7 pass, and the full Controls.Core.UnitTests suite 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

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>
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 38397

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 38397"

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Sep 10, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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>
@wbv-profile

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@kubaflo

kubaflo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Closing this alternate implementation in favor of the existing inflight/current fixes: #37893 (shared collection retention), #36746 (Clear() cleanup), and #36808 (the compatible reference-identity comparer).

The current patch also has two concrete ownership problems in GradientBrush.cs: DetachStop uses collection.Contains(stop) at line 149, but GradientStop.Equals compares values, so removing one of two distinct equal-color/equal-offset stops leaves the removed stop subscribed and parented. Separately, the finalizer calls DetachAllStops, which unconditionally clears Parent at line 164; collecting an earlier brush that shared the collection can therefore clear the parent assigned by a still-live successor brush.

This addresses real problems still visible on main, but we should reuse the existing inflight fixes for any backport rather than merge another implementation with these regressions. Additional regression cases can be carried forward as test-only improvements. Thanks for the detailed leak coverage.

@kubaflo kubaflo closed this Sep 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.