From fb484f870d245fc222b3360fe9ede4aa2f360419 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:52:17 +0000 Subject: [PATCH] [leak-fix] Fix ListView.RefreshCommand memory leak (Fixes #36344) ListView subscribed to RefreshCommand.CanExecuteChanged with a plain instance handler, so a long-lived ICommand whose CanExecuteChanged is a strong .NET event rooted the entire ListView and it was never released on unload. Replace the direct subscription with a WeakCommandSubscription (the same hardening already used by CommandElement, Button and RefreshView) so the command no longer keeps the ListView alive. Adds a Controls.Core.UnitTests regression test that fails without this fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Controls/src/Core/ListView/ListView.cs | 7 +++- .../tests/Core.UnitTests/ListViewTests.cs | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/ListView/ListView.cs b/src/Controls/src/Core/ListView/ListView.cs index 0b380d22773d..f1bdc94febfb 100644 --- a/src/Controls/src/Core/ListView/ListView.cs +++ b/src/Controls/src/Core/ListView/ListView.cs @@ -97,6 +97,8 @@ public class ListView : ItemsView, IListViewController, IElementConfigurat int _previousGroupSelected = -1; int _previousRowSelected = -1; + WeakCommandSubscription _refreshCommandSubscription; + /// /// Controls whether anything happens in BeginRefresh(), is set based on RefreshCommand.CanExecute /// @@ -763,12 +765,13 @@ void OnRefreshCommandChanged(ICommand oldCommand, ICommand newCommand) { if (oldCommand != null) { - oldCommand.CanExecuteChanged -= OnCommandCanExecuteChanged; + _refreshCommandSubscription?.Dispose(); + _refreshCommandSubscription = null; } if (newCommand != null) { - newCommand.CanExecuteChanged += OnCommandCanExecuteChanged; + _refreshCommandSubscription = new WeakCommandSubscription(this, newCommand, OnCommandCanExecuteChanged); RefreshAllowed = newCommand.CanExecute(null); } else diff --git a/src/Controls/tests/Core.UnitTests/ListViewTests.cs b/src/Controls/tests/Core.UnitTests/ListViewTests.cs index 8b0f905646c5..4a8024e2dab0 100644 --- a/src/Controls/tests/Core.UnitTests/ListViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/ListViewTests.cs @@ -35,6 +35,43 @@ public void TestConstructor() Assert.Equal(LayoutOptions.FillAndExpand, listView.VerticalOptions); } + [Fact] + public async Task RefreshCommandDoesNotLeakListView() + { + // A long-lived command whose CanExecuteChanged is a *strong* .NET event, as with a + // custom ICommand or CommunityToolkit's RelayCommand. (Microsoft.Maui.Controls.Command + // routes CanExecuteChanged through a WeakEventManager and would not reproduce the leak.) + ICommand command = new StrongCanExecuteCommand(); + + // Create the ListView in a separate method so no local in this frame roots it. + WeakReference CreateReference() + { + var listView = new ListView { RefreshCommand = command }; + return new WeakReference(listView); + } + + WeakReference reference = CreateReference(); + + await TestHelpers.Collect(); + + // The command out-lives the ListView; subscribing to its CanExecuteChanged must not + // keep the ListView (and its cells, templates and binding context) alive. + Assert.False(await reference.WaitForCollect(), "ListView should not be alive!"); + + GC.KeepAlive(command); + } + + sealed class StrongCanExecuteCommand : ICommand + { + public event EventHandler CanExecuteChanged; + + public bool CanExecute(object parameter) => true; + + public void Execute(object parameter) { } + + public void ChangeCanExecute() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + internal class ListItem { public string Name { get; set; }