Skip to content

Fix Button and MenuItem leak when a style sets Command - #22367

Merged
MrJul merged 4 commits into
AvaloniaUI:mainfrom
NathanDrake2406:fix/20588-command-style-leak
Oct 5, 2026
Merged

MrJul merged 4 commits into
AvaloniaUI:mainfrom
NathanDrake2406:fix/20588-command-style-leak

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

What does the pull request do?

Stops Button and MenuItem from leaking when a style setter supplies Command. Each control now subscribes to ICommand.CanExecuteChanged one time on attach and unsubscribes on detach, for a style-set Command as well as for a local value or binding.

What is the current behavior?

When a style sets Command, Button and MenuItem subscribe to CanExecuteChanged two times on attach and never unsubscribe. The command keeps a strong reference to the control. With a long-lived command, such as a view model command behind a recycled ListBox item template, every detached control stays in memory.

Both controls use ILogical.IsAttachedToLogicalTree to decide whether the Command property handler moves the subscription. StyledElement changes that state before it changes the styles, and before it calls the attach and detach overrides:

Step IsAttachedToLogicalTree Result
Attach: ApplyStyling sets Command true The property handler subscribes
Attach: OnAttachedToLogicalTree true The override subscribes again
Detach: InvalidateStyles clears Command false The property handler skips the unsubscribe
Detach: OnDetachedFromLogicalTree false Command is null, nothing to unsubscribe

What is the updated/expected behavior with this PR?

Scenario Before After
Style-set Command, control attached 2 subscriptions 1 subscription
Style-set Command, control detached 2 subscriptions remain 0 subscriptions
Local value or binding Command 1 on attach, 0 on detach No change

The new tests count the CanExecuteChanged subscribers of a command directly, so they do not depend on the garbage collector:

  • Button_Subscribes_Once_To_Style_Set_Command_CanExecuteChanged_When_Added_To_Logical_Tree
  • Button_Unsubscribes_From_Style_Set_Command_CanExecuteChanged_When_Removed_From_Logical_Tree
  • The same two tests for MenuItem
  • SplitButton_Subscribes_Once_To_Style_Set_Command_CanExecuteChanged_While_In_Logical_Tree

The first commit contains only the tests. At that commit the four Button and MenuItem tests fail with a count of 2. The SplitButton test passes at that commit, because SplitButton is not affected. It is a guard, not a reproduction.

Validation

Run from tests/Avalonia.Controls.UnitTests/bin/Debug/net10.0 with dotnet exec ./Avalonia.Controls.UnitTests.dll:

Filter Before the fix After the fix
--filter-method "*Style_Set_Command*" 4 of 5 fail 5 pass
--filter-class "*ButtonTests*" not run 114 pass
--filter-class "*MenuItem*" not run 61 pass

Avalonia.Controls also builds in Release with no warnings. I did not run the full test suite or the leak tests locally.

How was the solution implemented (if it's not obvious)?

The control owns the subscription, so the control tracks it. A private flag is set where OnAttachedToLogicalTree subscribes and cleared where OnDetachedFromLogicalTree unsubscribes. The Command property handler moves the subscription only while that flag is set.

This is the approach SplitButton already uses with its _isAttachedToLogicalTree field, which is why SplitButton does not have the bug.

Alternatives that this PR does not take:

Button.CanExecuteChanged still uses IsAttachedToLogicalTree to decide whether to evaluate CanExecute. That check does not affect the subscription and is unchanged.

Checklist

Breaking changes

None. The change adds a private field to each control and changes no public API.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #20588
Fixes #19619

…Command

The existing tests set Command as a local value. A Command that a style
setter supplies takes a different path: the style applies the value during
attach, and removes the value during detach, before the control overrides run.

For Button and MenuItem the new tests fail. Each control subscribes two times
on attach, and both subscriptions remain after the control leaves the logical
tree, so the command keeps the control alive.

The SplitButton test passes. SplitButton already handles this sequence, and
the test guards that behaviour.
…taches

When a style setter supplies Command, Button and MenuItem subscribe to
CanExecuteChanged two times on attach and do not unsubscribe on detach. The
command then holds the control, and every control behind it, for the lifetime
of the command.

Both controls decide whether to move the subscription from
ILogical.IsAttachedToLogicalTree. StyledElement changes that state before the
styles change and before the attach and detach overrides run:

- On attach, the style sets Command while the control already reports
  attached. The property handler subscribes, then OnAttachedToLogicalTree
  subscribes again.
- On detach, the style value goes away while the control already reports
  detached. The property handler skips the unsubscribe, then
  OnDetachedFromLogicalTree finds no Command to unsubscribe from.

Track the subscription state in the control instead. Set the flag where
OnAttachedToLogicalTree subscribes and clear the flag where
OnDetachedFromLogicalTree unsubscribes. The Command property handler then
moves the subscription only while the control owns one. SplitButton already
uses this approach.

Fixes AvaloniaUI#20588
Fixes AvaloniaUI#19619
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review October 2, 2026 02:06
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

NuGet packages

The packages for this pull request have been published (workflow run):

@MrJul MrJul added bug backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch area-perf labels Oct 2, 2026
Comment thread src/Avalonia.Controls/MenuItem.cs Resolved
The field name states its purpose. The comment only gave the history of
the change.
@MrJul
MrJul added this pull request to the merge queue Oct 5, 2026
Merged via the queue into AvaloniaUI:main with commit b567314 Oct 5, 2026
11 checks passed
grokys pushed a commit to Evan260/Avalonia that referenced this pull request Oct 7, 2026
)

* test(controls): cover CanExecuteChanged subscription for a style-set Command

The existing tests set Command as a local value. A Command that a style
setter supplies takes a different path: the style applies the value during
attach, and removes the value during detach, before the control overrides run.

For Button and MenuItem the new tests fail. Each control subscribes two times
on attach, and both subscriptions remain after the control leaves the logical
tree, so the command keeps the control alive.

The SplitButton test passes. SplitButton already handles this sequence, and
the test guards that behaviour.

* fix(controls): release a style-set Command when Button or MenuItem detaches

When a style setter supplies Command, Button and MenuItem subscribe to
CanExecuteChanged two times on attach and do not unsubscribe on detach. The
command then holds the control, and every control behind it, for the lifetime
of the command.

Both controls decide whether to move the subscription from
ILogical.IsAttachedToLogicalTree. StyledElement changes that state before the
styles change and before the attach and detach overrides run:

- On attach, the style sets Command while the control already reports
  attached. The property handler subscribes, then OnAttachedToLogicalTree
  subscribes again.
- On detach, the style value goes away while the control already reports
  detached. The property handler skips the unsubscribe, then
  OnDetachedFromLogicalTree finds no Command to unsubscribe from.

Track the subscription state in the control instead. Set the flag where
OnAttachedToLogicalTree subscribes and clear the flag where
OnDetachedFromLogicalTree unsubscribes. The Command property handler then
moves the subscription only while the control owns one. SplitButton already
uses this approach.

Fixes AvaloniaUI#20588
Fixes AvaloniaUI#19619

* chore: trim code comments

* chore: remove the comment on _isSubscribedToCommand

The field name states its purpose. The comment only gave the history of
the change.
MrJul pushed a commit that referenced this pull request Oct 9, 2026
* test(controls): cover CanExecuteChanged subscription for a style-set Command

The existing tests set Command as a local value. A Command that a style
setter supplies takes a different path: the style applies the value during
attach, and removes the value during detach, before the control overrides run.

For Button and MenuItem the new tests fail. Each control subscribes two times
on attach, and both subscriptions remain after the control leaves the logical
tree, so the command keeps the control alive.

The SplitButton test passes. SplitButton already handles this sequence, and
the test guards that behaviour.

* fix(controls): release a style-set Command when Button or MenuItem detaches

When a style setter supplies Command, Button and MenuItem subscribe to
CanExecuteChanged two times on attach and do not unsubscribe on detach. The
command then holds the control, and every control behind it, for the lifetime
of the command.

Both controls decide whether to move the subscription from
ILogical.IsAttachedToLogicalTree. StyledElement changes that state before the
styles change and before the attach and detach overrides run:

- On attach, the style sets Command while the control already reports
  attached. The property handler subscribes, then OnAttachedToLogicalTree
  subscribes again.
- On detach, the style value goes away while the control already reports
  detached. The property handler skips the unsubscribe, then
  OnDetachedFromLogicalTree finds no Command to unsubscribe from.

Track the subscription state in the control instead. Set the flag where
OnAttachedToLogicalTree subscribes and clear the flag where
OnDetachedFromLogicalTree unsubscribes. The Command property handler then
moves the subscription only while the control owns one. SplitButton already
uses this approach.

Fixes #20588
Fixes #19619

* chore: trim code comments

* chore: remove the comment on _isSubscribedToCommand

The field name states its purpose. The comment only gave the history of
the change.
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak when setting command in style Memory Leak in MenuItem due to incorrect Command.CanExecuteChanged subscription/unsubscription

3 participants