Browse Source

fix: ICommadSource Implemetation (#15496)

* test: CommandParameter does not change between CanExecute and Execute

* feat: CommandParameter does not change between CanExecute and Execute

* test: update
workgroupengineering 1 year ago
parent
commit
81d2e7d4ef

+ 29 - 15
src/Avalonia.Controls/Button.cs

@@ -34,8 +34,10 @@ namespace Avalonia.Controls
     [PseudoClasses(pcFlyoutOpen, pcPressed)]
     public class Button : ContentControl, ICommandSource, IClickableControl
     {
-        private const string pcPressed    = ":pressed";
+        private const string pcPressed = ":pressed";
         private const string pcFlyoutOpen = ":flyout-open";
+        private EventHandler? _canExecuteChangeHandler = default;
+        private EventHandler CanExecuteChangedHandler => _canExecuteChangeHandler ??= new(CanExecuteChanged);
 
         /// <summary>
         /// Defines the <see cref="ClickMode"/> property.
@@ -250,10 +252,11 @@ namespace Avalonia.Controls
 
             base.OnAttachedToLogicalTree(e);
 
-            if (Command != null)
+            (var command, var parameter) = (Command, CommandParameter);
+            if (command is not null)
             {
-                Command.CanExecuteChanged += CanExecuteChanged;
-                CanExecuteChanged(this, EventArgs.Empty);
+                command.CanExecuteChanged += CanExecuteChangedHandler;
+                CanExecuteChanged(command, parameter);
             }
         }
 
@@ -269,9 +272,9 @@ namespace Avalonia.Controls
 
             base.OnDetachedFromLogicalTree(e);
 
-            if (Command != null)
+            if (Command is { } command)
             {
-                Command.CanExecuteChanged -= CanExecuteChanged;
+                command.CanExecuteChanged -= CanExecuteChangedHandler;
             }
         }
 
@@ -343,9 +346,10 @@ namespace Avalonia.Controls
                 var e = new RoutedEventArgs(ClickEvent);
                 RaiseEvent(e);
 
-                if (!e.Handled && Command?.CanExecute(CommandParameter) == true)
+                (var command, var parameter) = (Command, CommandParameter);
+                if (!e.Handled && command is not null && command.CanExecute(parameter))
                 {
-                    Command.Execute(CommandParameter);
+                    command.Execute(parameter);
                     e.Handled = true;
                 }
             }
@@ -451,25 +455,24 @@ namespace Avalonia.Controls
 
             if (change.Property == CommandProperty)
             {
+                var (oldValue, newValue) = change.GetOldAndNewValue<ICommand?>();
                 if (((ILogical)this).IsAttachedToLogicalTree)
                 {
-                    var (oldValue, newValue) = change.GetOldAndNewValue<ICommand?>();
                     if (oldValue is ICommand oldCommand)
                     {
-                        oldCommand.CanExecuteChanged -= CanExecuteChanged;
+                        oldCommand.CanExecuteChanged -= CanExecuteChangedHandler;
                     }
 
                     if (newValue is ICommand newCommand)
                     {
-                        newCommand.CanExecuteChanged += CanExecuteChanged;
+                        newCommand.CanExecuteChanged += CanExecuteChangedHandler;
                     }
                 }
-
-                CanExecuteChanged(this, EventArgs.Empty);
+                CanExecuteChanged(newValue, CommandParameter);
             }
             else if (change.Property == CommandParameterProperty)
             {
-                CanExecuteChanged(this, EventArgs.Empty);
+                CanExecuteChanged(Command, change.NewValue);
             }
             else if (change.Property == IsCancelProperty)
             {
@@ -557,7 +560,18 @@ namespace Avalonia.Controls
         /// <param name="e">The event args.</param>
         private void CanExecuteChanged(object? sender, EventArgs e)
         {
-            var canExecute = Command == null || Command.CanExecute(CommandParameter);
+            CanExecuteChanged(Command, CommandParameter);
+        }
+
+        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+        private void CanExecuteChanged(ICommand? command, object? parameter)
+        {
+            if (!((ILogical)this).IsAttachedToLogicalTree)
+            {
+                return;
+            }
+
+            var canExecute = command == null || command.CanExecute(parameter);
 
             if (canExecute != _commandCanExecute)
             {

+ 47 - 32
src/Avalonia.Controls/MenuItem.cs

@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using Avalonia.Reactive;
 using System.Windows.Input;
 using Avalonia.Automation.Peers;
 using Avalonia.Controls.Metadata;
@@ -13,7 +12,7 @@ using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.LogicalTree;
-using Avalonia.Layout;
+using Avalonia.Reactive;
 
 namespace Avalonia.Controls
 {
@@ -24,6 +23,9 @@ namespace Avalonia.Controls
     [PseudoClasses(":separator", ":radio", ":toggle", ":checked", ":icon", ":open", ":pressed", ":selected")]
     public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable, ICommandSource, IClickableControl, IRadioButton
     {
+        private EventHandler? _canExecuteChangeHandler = default;
+        private EventHandler CanExecuteChangedHandler => _canExecuteChangeHandler ??= new(CanExecuteChanged);
+
         /// <summary>
         /// Defines the <see cref="Command"/> property.
         /// </summary>
@@ -83,7 +85,7 @@ namespace Avalonia.Controls
         /// </summary>
         public static readonly StyledProperty<string?> GroupNameProperty =
             RadioButton.GroupNameProperty.AddOwner<MenuItem>();
-        
+
         /// <summary>
         /// Defines the <see cref="Click"/> event.
         /// </summary>
@@ -292,7 +294,7 @@ namespace Avalonia.Controls
             get => GetValue(StaysOpenOnClickProperty);
             set => SetValue(StaysOpenOnClickProperty, value);
         }
-        
+
         /// <inheritdoc cref="IMenuItem.ToggleType" />
         public MenuItemToggleType ToggleType
         {
@@ -306,7 +308,7 @@ namespace Avalonia.Controls
             get => GetValue(IsCheckedProperty);
             set => SetValue(IsCheckedProperty, value);
         }
-        
+
         bool IRadioButton.IsChecked
         {
             get => IsChecked;
@@ -319,7 +321,7 @@ namespace Avalonia.Controls
             get => GetValue(GroupNameProperty);
             set => SetValue(GroupNameProperty, value);
         }
-        
+
         /// <summary>
         /// Gets or sets a value that indicates whether the <see cref="MenuItem"/> has a submenu.
         /// </summary>
@@ -413,15 +415,16 @@ namespace Avalonia.Controls
             {
                 SetCurrentValue(HotKeyProperty, _hotkey);
             }
-            
+
             base.OnAttachedToLogicalTree(e);
 
-            if (Command != null)
+            (var command, var parameter) = (Command, CommandParameter);
+            if (command is not null)
             {
-                Command.CanExecuteChanged += CanExecuteChanged;
+                command.CanExecuteChanged += CanExecuteChangedHandler;
             }
-            
-            TryUpdateCanExecute();
+
+            TryUpdateCanExecute(command, parameter);
 
             var parent = Parent;
 
@@ -437,7 +440,7 @@ namespace Avalonia.Controls
         protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
         {
             base.OnAttachedToVisualTree(e);
-            
+
             TryUpdateCanExecute();
         }
 
@@ -454,7 +457,7 @@ namespace Avalonia.Controls
 
             if (Command != null)
             {
-                Command.CanExecuteChanged -= CanExecuteChanged;
+                Command.CanExecuteChanged -= CanExecuteChangedHandler;
             }
         }
 
@@ -464,9 +467,10 @@ namespace Avalonia.Controls
         /// <param name="e">The click event args.</param>
         protected virtual void OnClick(RoutedEventArgs e)
         {
-            if (!e.Handled && Command?.CanExecute(CommandParameter) == true)
+            (var command, var parameter) = (Command, CommandParameter);
+            if (!e.Handled && command is not null && command.CanExecute(parameter) == true)
             {
-                Command.Execute(CommandParameter);
+                command.Execute(parameter);
                 e.Handled = true;
             }
         }
@@ -577,21 +581,25 @@ namespace Avalonia.Controls
         /// <param name="e">The event args.</param>
         private static void CommandChanged(AvaloniaPropertyChangedEventArgs e)
         {
-            if (e.Sender is MenuItem menuItem &&
-                ((ILogical)menuItem).IsAttachedToLogicalTree)
+            var newCommand = e.NewValue as ICommand;
+            if (e.Sender is MenuItem menuItem)
+
             {
-                if (e.OldValue is ICommand oldCommand)
+                if (((ILogical)menuItem).IsAttachedToLogicalTree)
                 {
-                    oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged;
-                }
+                    if (e.OldValue is ICommand oldCommand)
+                    {
+                        oldCommand.CanExecuteChanged -= menuItem.CanExecuteChangedHandler;
+                    }
 
-                if (e.NewValue is ICommand newCommand)
-                {
-                    newCommand.CanExecuteChanged += menuItem.CanExecuteChanged;
+                    if (newCommand is not null)
+                    {
+                        newCommand.CanExecuteChanged += menuItem.CanExecuteChangedHandler;
+                    }
                 }
-
-                menuItem.TryUpdateCanExecute();
+                menuItem.TryUpdateCanExecute(newCommand, menuItem.CommandParameter);
             }
+
         }
 
         /// <summary>
@@ -602,7 +610,8 @@ namespace Avalonia.Controls
         {
             if (e.Sender is MenuItem menuItem)
             {
-                menuItem.TryUpdateCanExecute();
+                (var command, var parameter) = (menuItem.Command, e.NewValue);
+                menuItem.TryUpdateCanExecute(command, parameter);
             }
         }
 
@@ -621,21 +630,27 @@ namespace Avalonia.Controls
         /// </summary>
         private void TryUpdateCanExecute()
         {
-            if (Command == null)
+            TryUpdateCanExecute(Command, CommandParameter);
+        }
+
+        [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
+        private void TryUpdateCanExecute(ICommand? command, object? parameter)
+        {
+            if (command == null)
             {
                 _commandCanExecute = !_commandBindingError;
                 UpdateIsEffectivelyEnabled();
                 return;
             }
-            
+
             //Perf optimization - only raise CanExecute event if the menu is open
             if (!((ILogical)this).IsAttachedToLogicalTree ||
                 Parent is MenuItem { IsSubMenuOpen: false })
             {
                 return;
             }
-            
-            var canExecute = Command.CanExecute(CommandParameter);
+
+            var canExecute = command.CanExecute(parameter);
             if (canExecute != _commandCanExecute)
             {
                 _commandCanExecute = canExecute;
@@ -720,7 +735,7 @@ namespace Avalonia.Controls
                 (MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnCheckedChanged(this);
             }
         }
-        
+
         /// <summary>
         /// Called when the <see cref="HeaderedSelectingItemsControl.Header"/> property changes.
         /// </summary>
@@ -834,7 +849,7 @@ namespace Avalonia.Controls
             SelectedItem = null;
         }
 
-        void ICommandSource.CanExecuteChanged(object sender, EventArgs e) => this.CanExecuteChanged(sender, e);
+        void ICommandSource.CanExecuteChanged(object sender, EventArgs e) => CanExecuteChangedHandler(sender, e);
 
         void IClickableControl.RaiseClick()
         {

+ 21 - 8
src/Avalonia.Controls/SplitButton/SplitButton.cs

@@ -136,7 +136,18 @@ namespace Avalonia.Controls
         /// <inheritdoc cref="ICommandSource.CanExecuteChanged"/>
         private void CanExecuteChanged(object? sender, EventArgs e)
         {
-            var canExecute = Command == null || Command.CanExecute(CommandParameter);
+            (var command, var parameter) = (Command, CommandParameter);
+            CanExecuteChanged(command, parameter);
+        }
+
+        private void CanExecuteChanged(ICommand? command, object? parameter)
+        {
+            if (!((ILogical)this).IsAttachedToLogicalTree)
+            {
+                return;
+            }
+
+            var canExecute = command is null || command.CanExecute(parameter);
 
             if (canExecute != _commandCanExecute)
             {
@@ -282,10 +293,11 @@ namespace Avalonia.Controls
         {
             if (e.Property == CommandProperty)
             {
+                // Must unregister events here while a reference to the old command still exists
+                var (oldValue, newValue) = e.GetOldAndNewValue<ICommand?>();
+
                 if (_isAttachedToLogicalTree)
                 {
-                    // Must unregister events here while a reference to the old command still exists
-                    var (oldValue, newValue) = e.GetOldAndNewValue<ICommand?>();
 
                     if (oldValue is ICommand oldCommand)
                     {
@@ -298,11 +310,11 @@ namespace Avalonia.Controls
                     }
                 }
 
-                CanExecuteChanged(this, EventArgs.Empty);
+                CanExecuteChanged(newValue, CommandParameter);
             }
-            else if (e.Property == CommandParameterProperty)
+            else if (e.Property == CommandParameterProperty && IsLoaded)
             {
-                CanExecuteChanged(this, EventArgs.Empty);
+                CanExecuteChanged(Command, e.NewValue);
             }
             else if (e.Property == FlyoutProperty)
             {
@@ -386,15 +398,16 @@ namespace Avalonia.Controls
         /// <param name="e">The event args from the internal Click event.</param>
         protected virtual void OnClickPrimary(RoutedEventArgs? e)
         {
+            (var command, var parameter) = (Command, CommandParameter);
             // Note: It is not currently required to check enabled status; however, this is a failsafe
             if (IsEffectivelyEnabled)
             {
                 var eventArgs = new RoutedEventArgs(ClickEvent);
                 RaiseEvent(eventArgs);
 
-                if (!eventArgs.Handled && Command?.CanExecute(CommandParameter) == true)
+                if (!eventArgs.Handled && command?.CanExecute(parameter) == true)
                 {
-                    Command.Execute(CommandParameter);
+                    command.Execute(parameter);
                     eventArgs.Handled = true;
                 }
             }

+ 9 - 0
tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_Tab.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using Avalonia.Controls;
 using Avalonia.Input;
+using Avalonia.Threading;
 using Avalonia.UnitTests;
 using Xunit;
 
@@ -1282,6 +1283,8 @@ namespace Avalonia.Base.UnitTests.Input
             Button expected;
             bool executed = false;
 
+            using var app = UnitTestApplication.Start(TestServices.StyledWindow);
+
             var top = new StackPanel
             {
                 [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
@@ -1304,6 +1307,12 @@ namespace Avalonia.Base.UnitTests.Input
                 }
             };
 
+            var testRoot = new TestRoot(top);
+
+            top.ApplyTemplate();
+
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
             var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next) as Button;
 
             Assert.Equal(expected.Name, result?.Name);

+ 151 - 61
tests/Avalonia.Controls.UnitTests/ButtonTests.cs

@@ -1,5 +1,7 @@
 using System;
-using System.Windows.Input;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.Controls.UnitTests.Utils;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Interactivity;
@@ -9,7 +11,6 @@ using Avalonia.Platform;
 using Avalonia.Rendering;
 using Avalonia.Threading;
 using Avalonia.UnitTests;
-using Avalonia.VisualTree;
 using Moq;
 using Xunit;
 using MouseButton = Avalonia.Input.MouseButton;
@@ -19,7 +20,7 @@ namespace Avalonia.Controls.UnitTests
     public class ButtonTests : ScopedTestBase
     {
         private MouseTestHelper _helper = new MouseTestHelper();
-        
+
         [Fact]
         public void Button_Is_Disabled_When_Command_Is_Disabled()
         {
@@ -100,6 +101,9 @@ namespace Avalonia.Controls.UnitTests
                 DataContext = new object(),
                 [!Button.CommandProperty] = new Binding("Command"),
             };
+            var root = new TestRoot { Child = target };
+
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
 
             Assert.True(target.IsEnabled);
             Assert.False(target.IsEffectivelyEnabled);
@@ -141,9 +145,9 @@ namespace Avalonia.Controls.UnitTests
             renderer.Setup(r => r.HitTest(It.IsAny<Point>(), It.IsAny<Visual>(), It.IsAny<Func<Visual, bool>>()))
                 .Returns<Point, Visual, Func<Visual, bool>>((p, r, f) =>
                     r.Bounds.Contains(p) ? new Visual[] { r } : new Visual[0]);
-            
+
             using var _ = UnitTestApplication.Start(TestServices.StyledWindow);
-            
+
             var root = new Window() { HitTesterOverride = renderer.Object };
             var target = new Button()
             {
@@ -188,7 +192,7 @@ namespace Avalonia.Controls.UnitTests
             target.Click += (s, e) => clicked = true;
 
             RaisePointerEntered(target);
-            RaisePointerMove(target, new Point(50,50));
+            RaisePointerMove(target, new Point(50, 50));
             RaisePointerPressed(target, 1, MouseButton.Left, new Point(50, 50));
             RaisePointerExited(target);
 
@@ -210,9 +214,9 @@ namespace Avalonia.Controls.UnitTests
                 .Returns<Point, Visual, Func<Visual, bool>>((p, r, f) =>
                     r.Bounds.Contains(p.Transform(r.RenderTransform.Value.Invert())) ?
                     new Visual[] { r } : new Visual[0]);
-            
+
             using var _ = UnitTestApplication.Start(TestServices.StyledWindow);
-            
+
             var root = new Window() { HitTesterOverride = renderer.Object };
             var target = new Button()
             {
@@ -298,16 +302,104 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Raises_Click_When_AccessKey_Raised()
         {
-            var command = new TestCommand(p => p is bool value && value);
-            var target = new Button { Command = command };
+            var raised = 0;
+            var ah = new AccessKeyHandler();
+            using var app = UnitTestApplication.Start(TestServices.StyledWindow.With(accessKeyHandler: ah));
+
+            var impl = CreateMockTopLevelImpl();
+            var command = new TestCommand(p => p is bool value && value, _ => raised++);
 
+            Button target;
+            var root = new TestTopLevel(impl.Object)
+            {
+                Template = CreateTemplate(),
+                Content = target = new Button
+                {
+                    Content = "_A",
+                    Command = command,
+                    Template = new FuncControlTemplate<Button>((parent, scope) =>
+                    {
+                        return new ContentPresenter
+                        {
+                            Name = "PART_ContentPresenter",
+                            [~ContentPresenter.ContentProperty] = new TemplateBinding(Button.ContentProperty),
+                            [~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(Button.ContentProperty),
+                            RecognizesAccessKey = true,
+                        }.RegisterInNameScope(scope);
+                    })
+                },
+            };
+
+            root.ApplyTemplate();
+            root.Presenter.UpdateChild();
+            target.ApplyTemplate();
+            target.Presenter.UpdateChild();
+
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
+            var accessKey = Key.A;
             target.CommandParameter = true;
-            Assert.True(target.IsEffectivelyEnabled);
+
+            RaiseAccessKey(root, accessKey);
+
+            Assert.Equal(1, raised);
 
             target.CommandParameter = false;
-            Assert.False(target.IsEffectivelyEnabled);
+
+            RaiseAccessKey(root, accessKey);
+
+            Assert.Equal(1, raised);
+
+            static FuncControlTemplate<TestTopLevel> CreateTemplate()
+            {
+                return new FuncControlTemplate<TestTopLevel>((x, scope) =>
+                    new ContentPresenter
+                    {
+                        Name = "PART_ContentPresenter",
+                        [~ContentPresenter.ContentProperty] = new TemplateBinding(ContentControl.ContentProperty),
+                        [~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(ContentControl.ContentTemplateProperty)
+                    }.RegisterInNameScope(scope));
+            }
+
+            static Mock<ITopLevelImpl> CreateMockTopLevelImpl(bool setupProperties = false)
+            {
+                var topLevel = new Mock<ITopLevelImpl>();
+                if (setupProperties)
+                    topLevel.SetupAllProperties();
+                topLevel.Setup(x => x.RenderScaling).Returns(1);
+                topLevel.Setup(x => x.Compositor).Returns(RendererMocks.CreateDummyCompositor());
+                return topLevel;
+            }
+
+            static void RaiseAccessKey(IInputElement target, Key accessKey)
+            {
+                KeyDown(target, Key.LeftAlt);
+                KeyDown(target, accessKey, KeyModifiers.Alt);
+                KeyUp(target, accessKey, KeyModifiers.Alt);
+                KeyUp(target, Key.LeftAlt);
+            }
+
+            static void KeyDown(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None)
+            {
+                target.RaiseEvent(new KeyEventArgs
+                {
+                    RoutedEvent = InputElement.KeyDownEvent,
+                    Key = key,
+                    KeyModifiers = modifiers,
+                });
+            }
+
+            static void KeyUp(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None)
+            {
+                target.RaiseEvent(new KeyEventArgs
+                {
+                    RoutedEvent = InputElement.KeyUpEvent,
+                    Key = key,
+                    KeyModifiers = modifiers,
+                });
+            }
         }
-        
+
         [Fact]
         public void Button_Invokes_Doesnt_Execute_When_Button_Disabled()
         {
@@ -321,7 +413,7 @@ namespace Avalonia.Controls.UnitTests
 
             Assert.Equal(0, raised);
         }
-        
+
         [Fact]
         public void Button_IsDefault_Works()
         {
@@ -331,13 +423,13 @@ namespace Avalonia.Controls.UnitTests
                 var target = new Button();
                 var window = new Window { Content = target };
                 window.Show();
-                
+
                 target.Click += (s, e) => ++raised;
 
                 target.IsDefault = false;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Enter));
                 Assert.Equal(0, raised);
-                
+
                 target.IsDefault = true;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Enter));
                 Assert.Equal(1, raised);
@@ -345,18 +437,18 @@ namespace Avalonia.Controls.UnitTests
                 target.IsDefault = false;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Enter));
                 Assert.Equal(1, raised);
-                
+
                 target.IsDefault = true;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Enter));
                 Assert.Equal(2, raised);
-                
+
                 window.Content = null;
                 // To check if handler was raised on the button, when it's detached, we need to pass it as a source manually.
                 window.RaiseEvent(CreateKeyDownEvent(Key.Enter, target));
                 Assert.Equal(2, raised);
             }
         }
-        
+
         [Fact]
         public void Button_IsCancel_Works()
         {
@@ -366,13 +458,13 @@ namespace Avalonia.Controls.UnitTests
                 var target = new Button();
                 var window = new Window { Content = target };
                 window.Show();
-                
+
                 target.Click += (s, e) => ++raised;
 
                 target.IsCancel = false;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Escape));
                 Assert.Equal(0, raised);
-                
+
                 target.IsCancel = true;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Escape));
                 Assert.Equal(1, raised);
@@ -380,17 +472,45 @@ namespace Avalonia.Controls.UnitTests
                 target.IsCancel = false;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Escape));
                 Assert.Equal(1, raised);
-                
+
                 target.IsCancel = true;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Escape));
                 Assert.Equal(2, raised);
-                
+
                 window.Content = null;
                 window.RaiseEvent(CreateKeyDownEvent(Key.Escape, target));
                 Assert.Equal(2, raised);
             }
         }
 
+        [Fact]
+        public void Button_CommandParameter_Does_Not_Change_While_Execution()
+        {
+            var target = new Button();
+            object lastParamenter = "A";
+            var generator = new Random();
+            var onlyOnce = false;
+            var command = new TestCommand(parameter =>
+            {
+                if (!onlyOnce)
+                {
+                    onlyOnce = true;
+                    target.CommandParameter = generator.Next();
+                }
+                lastParamenter = parameter;
+                return true;
+            },
+            parameter =>
+            {
+                Assert.Equal(lastParamenter, parameter);
+            });
+            target.CommandParameter = lastParamenter;
+            target.Command = command;
+            var root = new TestRoot { Child = target };
+
+            (target as IClickableControl).RaiseClick();
+        }
+
         private KeyEventArgs CreateKeyDownEvent(Key key, Interactive source = null)
         {
             return new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, Source = source };
@@ -421,50 +541,20 @@ namespace Avalonia.Controls.UnitTests
             _helper.Move(button, pos);
         }
 
-        private class TestCommand : ICommand
-        {
-            private readonly Func<object, bool> _canExecute;
-            private readonly Action<object> _execute;
-            private EventHandler _canExecuteChanged;
-            private bool _enabled = true;
 
-            public TestCommand(bool enabled = true)
-            {
-                _enabled = enabled;
-                _canExecute = _ => _enabled;
-                _execute = _ => { };
-            }
 
-            public TestCommand(Func<object, bool> canExecute, Action<object> execute = null)
-            {
-                _canExecute = canExecute;
-                _execute = execute ?? (_ => { });
-            }
-
-            public bool IsEnabled
-            {
-                get { return _enabled; }
-                set
-                {
-                    if (_enabled != value)
-                    {
-                        _enabled = value;
-                        _canExecuteChanged?.Invoke(this, EventArgs.Empty);
-                    }
-                }
-            }
-
-            public int SubscriptionCount { get; private set; }
+        private class TestTopLevel : TopLevel
+        {
+            private readonly ILayoutManager _layoutManager;
+            public bool IsClosed { get; private set; }
 
-            public event EventHandler CanExecuteChanged
+            public TestTopLevel(ITopLevelImpl impl, ILayoutManager layoutManager = null)
+                : base(impl)
             {
-                add { _canExecuteChanged += value; ++SubscriptionCount; }
-                remove { _canExecuteChanged -= value; --SubscriptionCount; }
+                _layoutManager = layoutManager ?? new LayoutManager(this);
             }
 
-            public bool CanExecute(object parameter) => _canExecute(parameter);
-
-            public void Execute(object parameter) => _execute(parameter);
+            private protected override ILayoutManager CreateLayoutManager() => _layoutManager;
         }
     }
 }

+ 57 - 52
tests/Avalonia.Controls.UnitTests/MenuItemTests.cs

@@ -1,15 +1,14 @@
 using System;
-using System.Collections;
 using System.Collections.Generic;
 using System.Linq;
-using System.Windows.Input;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Templates;
-using Avalonia.Controls.Utils;
+using Avalonia.Controls.UnitTests.Utils;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Platform;
 using Avalonia.Styling;
+using Avalonia.Threading;
 using Avalonia.UnitTests;
 using Moq;
 using Xunit;
@@ -100,7 +99,9 @@ namespace Avalonia.Controls.UnitTests
                 [!MenuItem.CommandProperty] = new Binding("Command"),
             };
             var root = new TestRoot { Child = target };
-                
+
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
             Assert.True(target.IsEnabled);
             Assert.False(target.IsEffectivelyEnabled);
 
@@ -165,7 +166,7 @@ namespace Avalonia.Controls.UnitTests
             root.Child = null;
             Assert.Equal(0, command.SubscriptionCount);
         }
-        
+
         [Fact]
         public void MenuItem_Invokes_CanExecute_When_Added_To_Logical_Tree_And_CommandParameter_Changed()
         {
@@ -173,13 +174,15 @@ namespace Avalonia.Controls.UnitTests
             var target = new MenuItem { Command = command };
             var root = new TestRoot { Child = target };
 
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
             target.CommandParameter = true;
             Assert.True(target.IsEffectivelyEnabled);
 
             target.CommandParameter = false;
             Assert.False(target.IsEffectivelyEnabled);
         }
-        
+
         [Fact]
         public void MenuItem_Does_Not_Invoke_CanExecute_When_ContextMenu_Closed()
         {
@@ -197,7 +200,8 @@ namespace Avalonia.Controls.UnitTests
                 window.ApplyStyling();
                 window.ApplyTemplate();
                 window.Presenter.ApplyTemplate();
-                
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 Assert.True(target.IsEffectivelyEnabled);
                 target.Command = command;
                 Assert.Equal(0, canExecuteCallCount);
@@ -207,8 +211,9 @@ namespace Avalonia.Controls.UnitTests
 
                 command.RaiseCanExecuteChanged();
                 Assert.Equal(0, canExecuteCallCount);
-                
+
                 contextMenu.Open();
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
                 Assert.Equal(3, canExecuteCallCount);// 3 because popup is changing logical child and moreover we need to invalidate again after the item is attached to the visual tree
 
                 command.RaiseCanExecuteChanged();
@@ -218,7 +223,7 @@ namespace Avalonia.Controls.UnitTests
                 Assert.Equal(5, canExecuteCallCount);
             }
         }
-        
+
         [Fact]
         public void MenuItem_Does_Not_Invoke_CanExecute_When_MenuFlyout_Closed()
         {
@@ -237,7 +242,7 @@ namespace Avalonia.Controls.UnitTests
                 window.ApplyStyling();
                 window.ApplyTemplate();
                 window.Presenter.ApplyTemplate();
-                
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
                 Assert.True(target.IsEffectivelyEnabled);
                 target.Command = command;
                 Assert.Equal(0, canExecuteCallCount);
@@ -249,6 +254,7 @@ namespace Avalonia.Controls.UnitTests
                 Assert.Equal(0, canExecuteCallCount);
 
                 flyout.ShowAt(button);
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
                 Assert.Equal(2, canExecuteCallCount); // 2 because we need to invalidate after the item is attached to the visual tree
 
                 command.RaiseCanExecuteChanged();
@@ -258,7 +264,7 @@ namespace Avalonia.Controls.UnitTests
                 Assert.Equal(4, canExecuteCallCount);
             }
         }
-        
+
         [Fact]
         public void MenuItem_Does_Not_Invoke_CanExecute_When_Parent_MenuItem_Closed()
         {
@@ -278,7 +284,9 @@ namespace Avalonia.Controls.UnitTests
                 window.ApplyTemplate();
                 window.Presenter.ApplyTemplate();
                 contextMenu.Open();
-                
+
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 Assert.True(target.IsEffectivelyEnabled);
                 target.Command = command;
                 Assert.Equal(0, canExecuteCallCount);
@@ -377,7 +385,7 @@ namespace Avalonia.Controls.UnitTests
             var panel = Assert.IsType<StackPanel>(menu.Presenter.Panel);
             Assert.Equal(2, panel.Children.Count);
 
-            for (var i = 0; i <  panel.Children.Count; i++)
+            for (var i = 0; i < panel.Children.Count; i++)
             {
                 var menuItem = Assert.IsType<MenuItem>(panel.Children[i]);
 
@@ -501,7 +509,7 @@ namespace Avalonia.Controls.UnitTests
 
             var window = new Window { Content = menu };
             window.Show();
-            
+
             Assert.False(menuItem1.IsChecked);
             Assert.True(menuItem2.IsChecked);
             Assert.False(menuItem3.IsChecked);
@@ -512,7 +520,7 @@ namespace Avalonia.Controls.UnitTests
             Assert.False(menuItem2.IsChecked);
             Assert.True(menuItem3.IsChecked);
         }
-        
+
         [Fact]
         public void Radio_Menu_Group_Can_Be_Changed_In_Runtime()
         {
@@ -541,7 +549,7 @@ namespace Avalonia.Controls.UnitTests
 
             var window = new Window { Content = menu };
             window.Show();
-            
+
             Assert.False(menuItem1.IsChecked);
             Assert.True(menuItem2.IsChecked);
             Assert.False(menuItem3.IsChecked);
@@ -555,12 +563,12 @@ namespace Avalonia.Controls.UnitTests
 
             menuItem3.GroupName = null;
             menuItem1.IsChecked = true;
-            
+
             Assert.True(menuItem1.IsChecked);
             Assert.False(menuItem2.IsChecked);
             Assert.True(menuItem3.IsChecked);
         }
-        
+
         [Fact]
         public void Radio_MenuItem_In_Same_Group_But_Submenu_Is_Unchecked()
         {
@@ -600,7 +608,7 @@ namespace Avalonia.Controls.UnitTests
 
             var window = new Window { Content = menu };
             window.Show();
-            
+
             Assert.False(menuItem1.IsChecked);
             Assert.False(menuItem2.IsChecked);
             Assert.True(menuItem3.IsChecked);
@@ -768,12 +776,40 @@ namespace Avalonia.Controls.UnitTests
             Assert.False(menuItem4.IsChecked);
         }
 
+        [Fact]
+        public void MenuItem_CommandParameter_Does_Not_Change_While_Execution()
+        {
+            var target = new MenuItem();
+            object lastParamenter = "A";
+            var generator = new Random();
+            var onlyOnce = false;
+            var command = new TestCommand(parameter =>
+            {
+                if (!onlyOnce)
+                {
+                    onlyOnce = true;
+                    target.CommandParameter = generator.Next();
+                }
+                lastParamenter = parameter;
+                return true;
+            },
+            parameter =>
+            {
+                Assert.Equal(lastParamenter, parameter);
+            });
+            target.CommandParameter = lastParamenter;
+            target.Command = command;
+            var root = new TestRoot { Child = target };
+
+            (target as IClickableControl).RaiseClick();
+        }
+
         private IDisposable Application()
         {
             var screen = new PixelRect(new PixelPoint(), new PixelSize(100, 100));
             var screenImpl = new Mock<IScreenImpl>();
             screenImpl.Setup(x => x.ScreenCount).Returns(1);
-            screenImpl.Setup(X => X.AllScreens).Returns( new[] { new Screen(1, screen, screen, true) });
+            screenImpl.Setup(X => X.AllScreens).Returns(new[] { new Screen(1, screen, screen, true) });
 
             var windowImpl = MockWindowingPlatform.CreateWindowMock();
             popupImpl = MockWindowingPlatform.CreatePopupMock(windowImpl.Object);
@@ -790,41 +826,10 @@ namespace Avalonia.Controls.UnitTests
             return UnitTestApplication.Start(services);
         }
 
-        private class TestCommand : ICommand
-        {
-            private readonly Func<object, bool> _canExecute;
-            private readonly Action<object> _execute;
-            private EventHandler _canExecuteChanged;
-
-            public TestCommand(bool enabled = true)
-                : this(_ => enabled, _ => { })
-            {
-            }
-
-            public TestCommand(Func<object, bool> canExecute, Action<object> execute = null)
-            {
-                _canExecute = canExecute;
-                _execute = execute ?? (_ => { });
-            }
-
-            public int SubscriptionCount { get; private set; }
-
-            public event EventHandler CanExecuteChanged
-            {
-                add { _canExecuteChanged += value; ++SubscriptionCount; }
-                remove { _canExecuteChanged -= value; --SubscriptionCount; }
-            }
-
-            public bool CanExecute(object parameter) => _canExecute(parameter);
-
-            public void Execute(object parameter) => _execute(parameter);
-
-            public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
-        }
 
         private record MenuViewModel(string Header)
         {
-            public IList<MenuViewModel> Children { get; set;}
+            public IList<MenuViewModel> Children { get; set; }
         }
     }
 }

+ 33 - 0
tests/Avalonia.Controls.UnitTests/SplitButtonTests.cs

@@ -0,0 +1,33 @@
+using System;
+using Avalonia.Controls.UnitTests.Utils;
+using Avalonia.Input;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests;
+
+public class SplitButtonTests : ScopedTestBase
+{
+    [Fact]
+    public void SplitButton_CommandParameter_Does_Not_Change_While_Execution()
+    {
+        var target = new SplitButton();
+        object lastParamenter = "A";
+        var generator = new Random();
+        var command = new TestCommand(parameter =>
+        {
+            target.CommandParameter = generator.Next();
+            lastParamenter = parameter;
+            return true;
+        },
+        parameter =>
+        {
+            Assert.Equal(lastParamenter, parameter);
+        });
+        target.CommandParameter = lastParamenter;
+        target.Command = command;
+        var root = new TestRoot { Child = target };
+
+        (target as IClickableControl).RaiseClick();
+    }
+}

+ 52 - 0
tests/Avalonia.Controls.UnitTests/Utils/TestCommand.cs

@@ -0,0 +1,52 @@
+using System;
+using System.Windows.Input;
+
+namespace Avalonia.Controls.UnitTests.Utils;
+
+internal class TestCommand : ICommand
+{
+    private readonly Func<object, bool> _canExecute;
+    private readonly Action<object> _execute;
+    private EventHandler _canExecuteChanged;
+    private bool _enabled = true;
+
+    public TestCommand(bool enabled = true)
+    {
+        _enabled = enabled;
+        _canExecute = _ => _enabled;
+        _execute = _ => { };
+    }
+
+    public TestCommand(Func<object, bool> canExecute, Action<object> execute = null)
+    {
+        _canExecute = canExecute;
+        _execute = execute ?? (_ => { });
+    }
+
+    public bool IsEnabled
+    {
+        get { return _enabled; }
+        set
+        {
+            if (_enabled != value)
+            {
+                _enabled = value;
+                _canExecuteChanged?.Invoke(this, EventArgs.Empty);
+            }
+        }
+    }
+
+    public int SubscriptionCount { get; private set; }
+
+    public event EventHandler CanExecuteChanged
+    {
+        add { _canExecuteChanged += value; ++SubscriptionCount; }
+        remove { _canExecuteChanged -= value; --SubscriptionCount; }
+    }
+
+    public bool CanExecute(object parameter) => _canExecute(parameter);
+
+    public void Execute(object parameter) => _execute(parameter);
+
+    public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
+}