Skip to content

GradientBrush.GradientStops.Clear() leaks brushes when stops are shared #36743

Closed

Description

Description

GradientBrush subscribes to PropertyChanged on each GradientStop. When Clear() is called, ObservableCollection fires a Reset notification with e.OldItems == null. The old handler only unsubscribed from e.OldItems, so shared stops were never unsubscribed — keeping brushes alive indefinitely.

Steps to Reproduce

1.Add the below code in MainPage.xaml and MainPage.xaml.cs
MainPage.xaml

<VerticalStackLayout Padding="20"
            Spacing="12">
        <Label Text="GradientBrush.GradientStops.Clear() Leak Repro"
               FontSize="18"
                FontAttributes="Bold"/>
        <Label Text="A shared GradientStop keeps the GradientBrush alive after Clear() because the CollectionChanged Reset action leaves e.OldItems null, so the PropertyChanged subscription is never removed."
               FontSize="13"
                TextColor="Gray"/>
        <Button Text="1. Create brushes using shared stops"
                Clicked="OnCreateClicked"
                AutomationId="CreateButton"/>
        <Button Text="2. Clear() GradientStops and release"
                Clicked="OnClearAndReleaseClicked"
                AutomationId="ClearButton"/>
        <Button Text="3. Force GC and check leaks"
                Clicked="OnCheckLeaksClicked"
                AutomationId="CheckButton"/>
        <Label x:Name="StatusLabel"
                AutomationId="StatusLabel"
               FontSize="15"
                TextColor="DarkBlue"/>
        <BoxView x:Name="PreviewBox"
                HeightRequest="60"
                CornerRadius="8"/>
    </VerticalStackLayout>

MainPage.xaml.cs

public partial class MainPage : ContentPage
{
	// Shared GradientStops kept alive externally — simulates a shared resource
	// (e.g. a style resource or static geometry reused across pages).
	readonly GradientStop _sharedStop1 = new GradientStop(Colors.DeepSkyBlue, 0f);
	readonly GradientStop _sharedStop2 = new GradientStop(Colors.MediumVioletRed, 1f);

	const int BrushCount = 50;

	// Weak references so we can observe whether the brushes and boxes are collected.
	List<WeakReference> _brushRefs = new();
	List<WeakReference> _boxRefs = new();

	// Strong references released on step 2.
	List<LinearGradientBrush> _brushes = new();
	List<BoxView> _boxes = new();

	public MainPage()
	{
		InitializeComponent();
		UpdateStatus("Step 1: Press \"Create\" to allocate brushes.");
	}

	void OnCreateClicked(object sender, EventArgs e)
	{
		_brushes.Clear();
		_boxes.Clear();
		_brushRefs.Clear();
		_boxRefs.Clear();

		for (int i = 0; i < BrushCount; i++)
		{
			var brush = new LinearGradientBrush
			{
				StartPoint = new Point(0, 0),
				EndPoint = new Point(1, 1),
			};

			// Add the shared stops — subscribes to their PropertyChanged.
			brush.GradientStops.Add(_sharedStop1);
			brush.GradientStops.Add(_sharedStop2);

			var box = new BoxView { HeightRequest = 4 };
			box.Background = brush;

			_brushes.Add(brush);
			_boxes.Add(box);
			_brushRefs.Add(new WeakReference(brush));
			_boxRefs.Add(new WeakReference(box));
		}

		// Show one of the brushes on screen.
		PreviewBox.Background = _brushes[0];

		UpdateStatus($"Created {BrushCount} brushes with shared stops. Now press \"Clear\".");
	}

	void OnClearAndReleaseClicked(object sender, EventArgs e)
	{
		if (_brushes.Count == 0)
		{
			UpdateStatus("Press \"Create\" first.");
			return;
		}

		foreach (var brush in _brushes)
		{
			// BUG: Clear() fires CollectionChanged with Action=Reset.
			// e.OldItems is null so OnGradientStopCollectionChanged never
			// unsubscribes the shared stops, leaving them holding the brush alive.
			brush.GradientStops.Clear();
		}

		// Release all strong references.
		_brushes.Clear();
		_boxes.Clear();
		PreviewBox.Background = null;

		UpdateStatus($"Cleared GradientStops and released {BrushCount} brushes. Now press \"Check Leaks\".");
	}

	void OnCheckLeaksClicked(object sender, EventArgs e)
	{
		if (_brushRefs.Count == 0)
		{
			UpdateStatus("Press \"Create\" and then \"Clear\" first.");
			return;
		}

		GC.Collect();
		GC.WaitForPendingFinalizers();
		GC.Collect();

		int liveBrushes = _brushRefs.Count(r => r.IsAlive);
		int liveBoxes = _boxRefs.Count(r => r.IsAlive);

		string verdict = liveBrushes > 0
			? $"LEAK CONFIRMED: {liveBrushes}/{BrushCount} brushes still alive (shared stops hold them via PropertyChanged). Expected: 0."
			: $"No leak: 0/{BrushCount} brushes alive after GC.";

		StatusLabel.TextColor = liveBrushes > 0 ? Colors.Red : Colors.Green;
		UpdateStatus(verdict);
	}

	void UpdateStatus(string message)
	{
		StatusLabel.Text = message;
	}
}

2.Press "Create brushes using shared stops" — creates 50 brushes sharing the same 2 GradientStop instances.
3.Press "Clear() GradientStops and release" — clears each brush's stops and drops all strong refs.
4.Press "Force GC and check leaks".

Link to public reproduction project repository

No response

Version with bug

10.0.80

Is this a regression from previous behavior?

No, this is something new

Last version that worked well

Unknown/Other

Affected platforms

iOS, Android, Windows, macOS

Affected platform versions

No response

Did you find any workaround?

No response

Relevant log output

Activity

  1. Ahamed-Ali commented on Jul 23, 2026

    @Ahamed-Ali
    Contributor

    This issue has been validated in Visual Studio Code 1.123.0 with .NET MAUI versions 10.0.0 ,10.0.80 and 10.0.90. It was reproducible on iOS, Android, Windows and MacOS platform.

  2. added this to the .NET 10 SR10 milestone on Jul 23, 2026
  3. added 3 commits that reference this issue on Jul 26, 2026
    3fd966b
    9f8f621
    3bc6559
  4. 12 remaining items

  5. added 11 commits that reference this issue on Sep 2, 2026
    6456c27
    662832c
    71366c6
    cb09eaf
    162c5b5
    bb974c1
    8e088d1
    57a5c28
    101bee9
    dfdb334
    de351f9
  6. locked and limited conversation to collaborators on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-drawingShapes, Borders, Shadows, Graphics, BoxView, custom drawingpartner/syncfusionIssues / PR's with Syncfusion collaborationplatform/androidplatform/iosplatform/macosmacOS / Mac Catalystplatform/windowss/triagedIssue has been revieweds/verifiedVerified / Reproducible Issue ready for Engineering Triaget/bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions