Browse Source

Add Loaded/Unloaded Events (#8277)

* Add Loaded/Unloaded events

* Don't allow OnLoaded() twice unless OnUnloaded() is called

* Call OnLoadedCore within Render()

* Call OnLoadedCore() from OnAttachedToVisualTreeCore by scheduling it on the dispatcher

* Improve comments

* Queue loaded events

* Make the loaded queue static

* Make more members static per review

* Make sure control wasn't already scheduling for Loaded event

* Add locks around HashSet usage for when enumerating

* Remove from loaded queue in OnUnloadedCore() as failsafe

* Make Window raise its own Loaded/Unloaded events

* Attempt to fix leak tests to work with Loaded events

* Make WindowBase raise its own Loaded/Unloaded events

* Move hotkey leak tests to the LeakTest project

* Address some code review comments

* Attempt at actually queueing Loaded events again

* Fix typo

* Minor improvements

* Update controls benchmark

Co-authored-by: Max Katz <[email protected]>
Co-authored-by: Jumar Macato <[email protected]>
robloo 3 years ago
parent
commit
017788cd8e

+ 2 - 0
src/Avalonia.Base/Visual.cs

@@ -376,7 +376,9 @@ namespace Avalonia
                     if (e.OldValue is IAffectsRender oldValue)
                     {
                         if (sender._affectsRenderWeakSubscriber != null)
+                        {
                             InvalidatedWeakEvent.Unsubscribe(oldValue, sender._affectsRenderWeakSubscriber);
+                        }
                     }
 
                     if (e.NewValue is IAffectsRender newValue)

+ 188 - 4
src/Avalonia.Controls/Control.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.ComponentModel;
 using Avalonia.Automation.Peers;
 using Avalonia.Controls.Documents;
@@ -10,6 +11,7 @@ using Avalonia.Interactivity;
 using Avalonia.Media;
 using Avalonia.Rendering;
 using Avalonia.Styling;
+using Avalonia.Threading;
 using Avalonia.VisualTree;
 
 namespace Avalonia.Controls
@@ -53,21 +55,57 @@ namespace Avalonia.Controls
         /// Event raised when an element wishes to be scrolled into view.
         /// </summary>
         public static readonly RoutedEvent<RequestBringIntoViewEventArgs> RequestBringIntoViewEvent =
-            RoutedEvent.Register<Control, RequestBringIntoViewEventArgs>("RequestBringIntoView", RoutingStrategies.Bubble);
+            RoutedEvent.Register<Control, RequestBringIntoViewEventArgs>(
+                "RequestBringIntoView",
+                RoutingStrategies.Bubble);
 
         /// <summary>
         /// Provides event data for the <see cref="ContextRequested"/> event.
         /// </summary>
         public static readonly RoutedEvent<ContextRequestedEventArgs> ContextRequestedEvent =
-            RoutedEvent.Register<Control, ContextRequestedEventArgs>(nameof(ContextRequested),
+            RoutedEvent.Register<Control, ContextRequestedEventArgs>(
+                nameof(ContextRequested),
                 RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
         
+        /// <summary>
+        /// Defines the <see cref="Loaded"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> LoadedEvent =
+            RoutedEvent.Register<Control, RoutedEventArgs>(
+                nameof(Loaded),
+                RoutingStrategies.Direct);
+
+        /// <summary>
+        /// Defines the <see cref="Unloaded"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> UnloadedEvent =
+            RoutedEvent.Register<Control, RoutedEventArgs>(
+                nameof(Unloaded),
+                RoutingStrategies.Direct);
+
         /// <summary>
         /// Defines the <see cref="FlowDirection"/> property.
         /// </summary>
         public static readonly AttachedProperty<FlowDirection> FlowDirectionProperty =
-            AvaloniaProperty.RegisterAttached<Control, Control, FlowDirection>(nameof(FlowDirection), inherits: true);
-
+            AvaloniaProperty.RegisterAttached<Control, Control, FlowDirection>(
+                nameof(FlowDirection),
+                inherits: true);
+
+        // Note the following:
+        // _loadedQueue :
+        //   Is the queue where any control will be added to indicate that its loaded
+        //   event should be scheduled and called later.
+        // _loadedProcessingQueue :
+        //   Contains a copied snapshot of the _loadedQueue at the time when processing
+        //   starts and individual events are being fired. This was needed to avoid
+        //   exceptions if new controls were added in the Loaded event itself.
+
+        private static bool _isLoadedProcessing = false;
+        private static readonly HashSet<Control> _loadedQueue = new HashSet<Control>();
+        private static readonly HashSet<Control> _loadedProcessingQueue = new HashSet<Control>();
+
+        private bool _isAttachedToVisualTree = false;
+        private bool _isLoaded = false;
         private DataTemplates? _dataTemplates;
         private IControl? _focusAdorner;
         private AutomationPeer? _automationPeer;
@@ -108,6 +146,15 @@ namespace Avalonia.Controls
             set => SetValue(ContextFlyoutProperty, value);
         }
 
+        /// <summary>
+        /// Gets a value indicating whether the control is fully constructed in the visual tree
+        /// and both layout and render are complete.
+        /// </summary>
+        /// <remarks>
+        /// This is set to true while raising the <see cref="Loaded"/> event.
+        /// </remarks>
+        public bool IsLoaded => _isLoaded;
+
         /// <summary>
         /// Gets or sets a user-defined object attached to the control.
         /// </summary>
@@ -135,6 +182,35 @@ namespace Avalonia.Controls
             remove => RemoveHandler(ContextRequestedEvent, value);
         }
 
+        /// <summary>
+        /// Occurs when the control has been fully constructed in the visual tree and both
+        /// layout and render are complete.
+        /// </summary>
+        /// <remarks>
+        /// This event is guaranteed to occur after the control template is applied and references
+        /// to objects created after the template is applied are available. This makes it different
+        /// from OnAttachedToVisualTree which doesn't have these references. This event occurs at the
+        /// latest possible time in the control creation life-cycle.
+        /// </remarks>
+        public event EventHandler<RoutedEventArgs>? Loaded
+        {
+            add => AddHandler(LoadedEvent, value);
+            remove => RemoveHandler(LoadedEvent, value);
+        }
+
+        /// <summary>
+        /// Occurs when the control is removed from the visual tree.
+        /// </summary>
+        /// <remarks>
+        /// This is API symmetrical with <see cref="Loaded"/> and exists for compatibility with other
+        /// XAML frameworks; however, it behaves the same as OnDetachedFromVisualTree.
+        /// </remarks>
+        public event EventHandler<RoutedEventArgs>? Unloaded
+        {
+            add => AddHandler(UnloadedEvent, value);
+            remove => RemoveHandler(UnloadedEvent, value);
+        }
+
         public new IControl? Parent => (IControl?)base.Parent;
 
         /// <summary>
@@ -215,18 +291,124 @@ namespace Avalonia.Controls
         /// <returns>The control that receives the focus adorner.</returns>
         protected virtual IControl? GetTemplateFocusTarget() => this;
 
+        private static Action loadedProcessingAction = () =>
+        {
+            // Copy the loaded queue for processing
+            // There was a possibility of the "Collection was modified; enumeration operation may not execute."
+            // exception when only a single hash set was used. This could happen when new controls are added
+            // within the Loaded callback/event itself. To fix this, two hash sets are used and while one is
+            // being processed the other accepts adding new controls to process next.
+            _loadedProcessingQueue.Clear();
+            foreach (Control control in _loadedQueue)
+            {
+                _loadedProcessingQueue.Add(control);
+            }
+            _loadedQueue.Clear();
+
+            foreach (Control control in _loadedProcessingQueue)
+            {
+                control.OnLoadedCore();
+            }
+
+            _loadedProcessingQueue.Clear();
+            _isLoadedProcessing = false;
+
+            // Restart if any controls were added to the queue while processing
+            if (_loadedQueue.Count > 0)
+            {
+                _isLoadedProcessing = true;
+                Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded);
+            }
+        };
+
+        /// <summary>
+        /// Schedules <see cref="OnLoadedCore"/> to be called for this control.
+        /// For performance, it will be queued with other controls.
+        /// </summary>
+        internal void ScheduleOnLoadedCore()
+        {
+            if (_isLoaded == false)
+            {
+                bool isAdded = _loadedQueue.Add(this);
+
+                if (isAdded &&
+                    _isLoadedProcessing == false)
+                {
+                    _isLoadedProcessing = true;
+                    Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Invoked as the first step of marking the control as loaded and raising the
+        /// <see cref="Loaded"/> event.
+        /// </summary>
+        internal void OnLoadedCore()
+        {
+            if (_isLoaded == false &&
+                _isAttachedToVisualTree)
+            {
+                _isLoaded = true;
+                OnLoaded();
+            }
+        }
+
+        /// <summary>
+        /// Invoked as the first step of marking the control as unloaded and raising the
+        /// <see cref="Unloaded"/> event.
+        /// </summary>
+        internal void OnUnloadedCore()
+        {
+            if (_isLoaded)
+            {
+                // Remove from the loaded event queue here as a failsafe in case the control
+                // is detached before the dispatcher runs the Loaded jobs.
+                _loadedQueue.Remove(this);
+
+                _isLoaded = false;
+                OnUnloaded();
+            }
+        }
+
+        /// <summary>
+        /// Invoked just before the <see cref="Loaded"/> event.
+        /// </summary>
+        protected virtual void OnLoaded()
+        {
+            var eventArgs = new RoutedEventArgs(LoadedEvent);
+            eventArgs.Source = null;
+            RaiseEvent(eventArgs);
+        }
+
+        /// <summary>
+        /// Invoked just before the <see cref="Unloaded"/> event.
+        /// </summary>
+        protected virtual void OnUnloaded()
+        {
+            var eventArgs = new RoutedEventArgs(UnloadedEvent);
+            eventArgs.Source = null;
+            RaiseEvent(eventArgs);
+        }
+
         /// <inheritdoc/>
         protected sealed override void OnAttachedToVisualTreeCore(VisualTreeAttachmentEventArgs e)
         {
             base.OnAttachedToVisualTreeCore(e);
+            _isAttachedToVisualTree = true;
 
             InitializeIfNeeded();
+
+            ScheduleOnLoadedCore();
         }
 
         /// <inheritdoc/>
         protected sealed override void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArgs e)
         {
             base.OnDetachedFromVisualTreeCore(e);
+            _isAttachedToVisualTree = false;
+
+            OnUnloadedCore();
         }
 
         /// <inheritdoc/>
@@ -324,7 +506,9 @@ namespace Avalonia.Controls
                 var keymap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>()?.OpenContextMenu;
 
                 if (keymap is null)
+                {
                     return;
+                }
 
                 var matches = false;
 

+ 20 - 1
src/Avalonia.Controls/WindowBase.cs

@@ -169,7 +169,6 @@ namespace Avalonia.Controls
             }
         }
 
-
         [Obsolete("No longer used. Has no effect.")]
         protected IDisposable BeginAutoSizing() => Disposable.Empty;
 
@@ -186,6 +185,26 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <inheritdoc/>
+        protected override void OnClosed(EventArgs e)
+        {
+            // Window must manually raise Loaded/Unloaded events as it is a visual root and
+            // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events
+            OnUnloadedCore();
+
+            base.OnClosed(e);
+        }
+
+        /// <inheritdoc/>
+        protected override void OnOpened(EventArgs e)
+        {
+            // Window must manually raise Loaded/Unloaded events as it is a visual root and
+            // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events
+            ScheduleOnLoadedCore();
+
+            base.OnOpened(e);
+        }
+
         protected override void HandleClosed()
         {
             _ignoreVisibilityChange = true;

+ 18 - 0
tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Runtime.CompilerServices;
 using Avalonia.Controls;
+using Avalonia.Threading;
 using Avalonia.UnitTests;
 using BenchmarkDotNet.Attributes;
 
@@ -37,6 +38,21 @@ namespace Avalonia.Benchmarks.Layout
             _root.Child = calendar;
 
             _root.LayoutManager.ExecuteLayoutPass();
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+        }
+
+        [Benchmark]
+        [MethodImpl(MethodImplOptions.NoInlining)]
+        public void CreateCalendarWithLoaded()
+        {
+            using var subscription = Control.LoadedEvent.AddClassHandler<Control>((c, s) => { });
+
+            var calendar = new Calendar();
+
+            _root.Child = calendar;
+
+            _root.LayoutManager.ExecuteLayoutPass();
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
         }
 
         [Benchmark]
@@ -48,6 +64,7 @@ namespace Avalonia.Benchmarks.Layout
             _root.Child = button;
 
             _root.LayoutManager.ExecuteLayoutPass();
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
         }
 
         [Benchmark]
@@ -59,6 +76,7 @@ namespace Avalonia.Benchmarks.Layout
             _root.Child = textBox;
 
             _root.LayoutManager.ExecuteLayoutPass();
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
         }
 
         public void Dispose()

+ 1 - 109
tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs

@@ -12,6 +12,7 @@ using Moq;
 using Xunit;
 using Avalonia.Input.Raw;
 using Factory = System.Func<int, System.Action<object>, Avalonia.Controls.Window, Avalonia.AvaloniaObject>;
+using Avalonia.Threading;
 
 namespace Avalonia.Controls.UnitTests.Utils
 {
@@ -60,115 +61,6 @@ namespace Avalonia.Controls.UnitTests.Utils
             }
         }
 
-        [Fact]
-        public void HotKeyManager_Should_Release_Reference_When_Control_Detached()
-        {
-            using (AvaloniaLocator.EnterScope())
-            {
-                var styler = new Mock<Styler>();
-
-                AvaloniaLocator.CurrentMutable
-                    .Bind<IWindowingPlatform>().ToConstant(new WindowingPlatformMock())
-                    .Bind<IStyler>().ToConstant(styler.Object);
-
-                var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control);
-
-                WeakReference reference = null;
-
-                var tl = new Window();
-
-                new Action(() =>
-                {
-                    var button = new Button();
-                    reference = new WeakReference(button, true);
-                    tl.Content = button;
-                    tl.Template = CreateWindowTemplate();
-                    tl.ApplyTemplate();
-                    tl.Presenter.ApplyTemplate();
-                    HotKeyManager.SetHotKey(button, gesture1);
-
-                    // Detach the button from the logical tree, so there is no reference to it
-                    tl.Content = null;
-                    tl.ApplyTemplate();
-                })();
-
-
-                // The button should be collected since it's detached from the listbox
-                GC.Collect();
-                GC.WaitForPendingFinalizers();
-                GC.Collect();
-                GC.WaitForPendingFinalizers();
-
-                Assert.Null(reference?.Target);
-            }
-        }
-
-        [Fact]
-        public void HotKeyManager_Should_Release_Reference_When_Control_In_Item_Template_Detached()
-        {
-            using (UnitTestApplication.Start(TestServices.StyledWindow))
-            {
-                var styler = new Mock<Styler>();
-
-                AvaloniaLocator.CurrentMutable
-                    .Bind<IWindowingPlatform>().ToConstant(new WindowingPlatformMock())
-                    .Bind<IStyler>().ToConstant(styler.Object);
-
-                var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control);
-
-                var weakReferences = new List<WeakReference>();
-                var tl = new Window { SizeToContent = SizeToContent.WidthAndHeight, IsVisible = true };
-                var lm = tl.LayoutManager;
-
-                var keyGestures = new AvaloniaList<KeyGesture> { gesture1 };
-                var listBox = new ListBox
-                {
-                    Width = 100,
-                    Height = 100,
-                    VirtualizationMode = ItemVirtualizationMode.None,
-                    // Create a button with binding to the KeyGesture in the template and add it to references list
-                    ItemTemplate = new FuncDataTemplate(typeof(KeyGesture), (o, scope) =>
-                    {
-                        var keyGesture = o as KeyGesture;
-                        var button = new Button
-                        {
-                            DataContext = keyGesture, [!Button.HotKeyProperty] = new Binding("")
-                        };
-                        weakReferences.Add(new WeakReference(button, true));
-                        return button;
-                    })
-                };
-                // Add the listbox and render it
-                tl.Content = listBox;
-                lm.ExecuteInitialLayoutPass();
-                listBox.Items = keyGestures;
-                lm.ExecuteLayoutPass();
-
-                // Let the button detach when clearing the source items
-                keyGestures.Clear();
-                lm.ExecuteLayoutPass();
-                
-                // Add it again to double check,and render
-                keyGestures.Add(gesture1);
-                lm.ExecuteLayoutPass();
-                
-                keyGestures.Clear();
-                lm.ExecuteLayoutPass();
-                
-                // The button should be collected since it's detached from the listbox
-                GC.Collect();
-                GC.WaitForPendingFinalizers();
-                GC.Collect();
-                GC.WaitForPendingFinalizers();
-                
-                Assert.True(weakReferences.Count > 0);
-                foreach (var weakReference in weakReferences)
-                {
-                    Assert.Null(weakReference.Target);
-                }
-            }
-        }
-
         [Theory]
         [MemberData(nameof(ElementsFactory), parameters: true)]
         public void HotKeyManager_Should_Use_CommandParameter(string factoryName, Factory factory)

+ 162 - 3
tests/Avalonia.LeakTests/ControlTests.cs

@@ -3,7 +3,10 @@ using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.Linq;
 using System.Reactive.Disposables;
+
+using Avalonia.Collections;
 using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Shapes;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
@@ -67,6 +70,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<DataGrid>()).ObjectsCount));
             }
@@ -100,6 +106,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Canvas>()).ObjectsCount));
             }
@@ -141,6 +150,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Canvas>()).ObjectsCount));
             }
@@ -179,6 +191,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<TextBox>()).ObjectsCount));
                 dotMemory.Check(memory =>
@@ -216,6 +231,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<TextBox>()).ObjectsCount));
             }
@@ -261,6 +279,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<TextBox>()).ObjectsCount));
                 dotMemory.Check(memory =>
@@ -351,6 +372,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<TreeView>()).ObjectsCount));
             }
@@ -384,6 +408,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Slider>()).ObjectsCount));
             }
@@ -421,6 +448,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<TabItem>()).ObjectsCount));
             }
@@ -496,6 +526,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Canvas>()).ObjectsCount));
             }
@@ -536,9 +569,12 @@ namespace Avalonia.LeakTests
                     initialMenuCount = memory.GetObjects(where => where.Type.Is<ContextMenu>()).ObjectsCount;
                     initialMenuItemCount = memory.GetObjects(where => where.Type.Is<MenuItem>()).ObjectsCount;
                 });
-                
+
                 AttachShowAndDetachContextMenu(window);
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 Mock.Get(window.PlatformImpl).Invocations.Clear();
                 dotMemory.Check(memory =>
                     Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is<ContextMenu>()).ObjectsCount));
@@ -580,10 +616,13 @@ namespace Avalonia.LeakTests
                     initialMenuCount = memory.GetObjects(where => where.Type.Is<ContextMenu>()).ObjectsCount;
                     initialMenuItemCount = memory.GetObjects(where => where.Type.Is<MenuItem>()).ObjectsCount;
                 });
-                
+
                 BuildAndShowContextMenu(window);
                 BuildAndShowContextMenu(window);
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 Mock.Get(window.PlatformImpl).Invocations.Clear();
                 dotMemory.Check(memory =>
                     Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is<ContextMenu>()).ObjectsCount));
@@ -623,6 +662,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Path>()).ObjectsCount));
 
@@ -657,6 +699,9 @@ namespace Avalonia.LeakTests
 
                 var result = run();
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<ItemsRepeater>()).ObjectsCount));
             }
@@ -725,14 +770,128 @@ namespace Avalonia.LeakTests
 
                 Assert.Empty(lb.ItemContainerGenerator.Containers);
 
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
                 dotMemory.Check(memory =>
                     Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Canvas>()).ObjectsCount));
             }
         }
 
+        [Fact]
+        public void HotKeyManager_Should_Release_Reference_When_Control_Detached()
+        {
+            using (Start())
+            {
+                Func<Window> run = () =>
+                {
+                    var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control);
+                    var tl = new Window
+                    {
+                        Content = new ItemsRepeater(),
+                    };
+
+                    tl.Show();
+
+                    var button = new Button();
+                    tl.Content = button;
+                    tl.Template = CreateWindowTemplate();
+                    tl.ApplyTemplate();
+                    tl.Presenter.ApplyTemplate();
+                    HotKeyManager.SetHotKey(button, gesture1);
+
+                    // Detach the button from the logical tree, so there is no reference to it
+                    tl.Content = null;
+                    tl.ApplyTemplate();
+
+                    return tl;
+                };
+
+                var result = run();
+
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
+                dotMemory.Check(memory =>
+                    Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Button>()).ObjectsCount));
+            }
+        }
+
+        [Fact]
+        public void HotKeyManager_Should_Release_Reference_When_Control_In_Item_Template_Detached()
+        {
+            using (Start())
+            {
+                Func<Window> run = () =>
+                {
+                    var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control);
+
+                    var tl = new Window { SizeToContent = SizeToContent.WidthAndHeight, IsVisible = true };
+                    var lm = tl.LayoutManager;
+                    tl.Show();
+
+                    var keyGestures = new AvaloniaList<KeyGesture> { gesture1 };
+                    var listBox = new ListBox
+                    {
+                        Width = 100,
+                        Height = 100,
+                        VirtualizationMode = ItemVirtualizationMode.None,
+                        // Create a button with binding to the KeyGesture in the template and add it to references list
+                        ItemTemplate = new FuncDataTemplate(typeof(KeyGesture), (o, scope) =>
+                        {
+                            var keyGesture = o as KeyGesture;
+                            return new Button
+                            {
+                                DataContext = keyGesture,
+                                [!Button.HotKeyProperty] = new Binding("")
+                            };
+                        })
+                    };
+                    // Add the listbox and render it
+                    tl.Content = listBox;
+                    lm.ExecuteInitialLayoutPass();
+                    listBox.Items = keyGestures;
+                    lm.ExecuteLayoutPass();
+
+                    // Let the button detach when clearing the source items
+                    keyGestures.Clear();
+                    lm.ExecuteLayoutPass();
+
+                    // Add it again to double check,and render
+                    keyGestures.Add(gesture1);
+                    lm.ExecuteLayoutPass();
+
+                    keyGestures.Clear();
+                    lm.ExecuteLayoutPass();
+
+                    return tl;
+                };
+
+                var result = run();
+
+                // Process all Loaded events to free control reference(s)
+                Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+
+                dotMemory.Check(memory =>
+                    Assert.Equal(0, memory.GetObjects(where => where.Type.Is<Button>()).ObjectsCount));
+            }
+        }
+
+        private FuncControlTemplate CreateWindowTemplate()
+        {
+            return new FuncControlTemplate<Window>((parent, scope) =>
+            {
+                return new ContentPresenter
+                {
+                    Name = "PART_ContentPresenter",
+                    [~ContentPresenter.ContentProperty] = parent[~ContentControl.ContentProperty],
+                }.RegisterInNameScope(scope);
+            });
+        }
+
         private IDisposable Start()
         {
-            void Cleanup()
+            static void Cleanup()
             {
                 // KeyboardDevice holds a reference to the focused item.
                 KeyboardDevice.Instance.SetFocusedElement(null, NavigationMethod.Unspecified, KeyModifiers.None);