diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 3076d7a5eb..f24cd1943d 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -103,7 +103,7 @@ namespace Avalonia.Controls private static readonly HashSet _loadedQueue = new HashSet(); private static readonly HashSet _loadedProcessingQueue = new HashSet(); - private bool _isLoaded = false; + private LoadState _loadState = LoadState.Unloaded; private DataTemplates? _dataTemplates; private Control? _focusAdorner; private AutomationPeer? _automationPeer; @@ -151,7 +151,7 @@ namespace Avalonia.Controls /// /// This is set to true while raising the event. /// - public bool IsLoaded => _isLoaded; + public bool IsLoaded => _loadState == LoadState.Loaded; /// /// Gets or sets a user-defined object attached to the control. @@ -293,9 +293,10 @@ namespace Avalonia.Controls /// internal void ScheduleOnLoadedCore() { - if (_isLoaded == false) + if (_loadState == LoadState.Unloaded) { bool isAdded = _loadedQueue.Add(this); + _loadState = LoadState.LoadPending; if (isAdded && _isLoadedProcessing == false) @@ -312,13 +313,18 @@ namespace Avalonia.Controls /// internal void OnLoadedCore() { - if (_isLoaded == false && + if (_loadState == LoadState.LoadPending && ((ILogical)this).IsAttachedToLogicalTree) { - _isLoaded = true; + _loadState = LoadState.Loaded; OnLoaded(new RoutedEventArgs(LoadedEvent, this)); } + else + { + // We somehow got here while being detached? + _loadState = LoadState.Unloaded; + } } /// @@ -327,15 +333,21 @@ namespace Avalonia.Controls /// internal void OnUnloadedCore() { - if (_isLoaded) + switch (_loadState) { - // 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); + case LoadState.Loaded: + _loadState = LoadState.Unloaded; + + OnUnloaded(new RoutedEventArgs(UnloadedEvent, this)); + break; - _isLoaded = false; + case LoadState.LoadPending: + // 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); - OnUnloaded(new RoutedEventArgs(UnloadedEvent, this)); + _loadState = LoadState.Unloaded; + break; } } @@ -561,5 +573,12 @@ namespace Avalonia.Controls _loadedProcessingQueue.Clear(); _isLoadedProcessing = false; } + + private enum LoadState : byte + { + Unloaded, + Loaded, + LoadPending + } } } diff --git a/tests/Avalonia.Controls.UnitTests/LoadedTests.cs b/tests/Avalonia.Controls.UnitTests/LoadedTests.cs index 555ba02409..288b48c0ab 100644 --- a/tests/Avalonia.Controls.UnitTests/LoadedTests.cs +++ b/tests/Avalonia.Controls.UnitTests/LoadedTests.cs @@ -72,4 +72,38 @@ public class LoadedTests Assert.False(target.IsLoaded); } } + + [Fact] + public void Loaded_Should_Not_Be_Raised_If_Detached_From_Visual_Tree() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var loadedCount = 0; + var unloadedCount = 0; + var window = new Window(); + window.Show(); + + var target = new Button(); + + target.Loaded += (_, _) => loadedCount++; + target.Unloaded += (_, _) => unloadedCount++; + + Assert.Equal(0, loadedCount); + Assert.Equal(0, unloadedCount); + + // Attach to, then immediately detach from the visual tree. + window.Content = target; + window.Content = null; + + // Attach to another logical parent (this can actually happen outside tests with overlay popups) + ((ISetLogicalParent) target).SetParent(new Window()); + + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + // At this point, the control shouldn't have been loaded at all. + Assert.Null(target.VisualParent); + Assert.False(target.IsLoaded); + Assert.Equal(0, loadedCount); + Assert.Equal(0, unloadedCount); + } }