Dirkster99 / AvalonDock

Our own development branch of the well known WPF document docking library

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

All documents disappear if document stops close application in Caliburn.Micro

ryanvs opened this issue · comments

I noticed an issue where all of my documents disappeared even though I cancelled the close (CanCloseAsync returns false). When a document closes, it prompts the user to save (Yes, No, or Cancel) where cancel stops the document from closing. I had upgraded several components including AvalonDock, Gemini, and Caliburn.Micro, so I wasn't sure where the error originated. I'm fairly sure the issue is in AvalonDock.

I created a test repo to demonstrate the issue: https://github.com/ryanvs/AvalonDockIssue - I also compared AvalonDock to a standard TabControl.

I haven't figured out exactly what is happening, but after the close is stopped, the PART_SelectedContentHost is empty and the ActualWidth is 0.
AvalonDock_App_5_SnoopAfterCloseCancelled

Hi Ryan,

I am not sure about the CanCloseAsync and CaliburnMicro as I have no relevant experience with this framework.

I use the:

binding in AvalonDock to close documents when I need to close them vie ViewModel in an MVVM app.

Not sure why I would want to cancel this because if so you could evaluate relevant conditions before invoking the comman(?).

For the sake of understanding the problem better it might be more useful to manipulate one of the demo apps in this repo to better indicate the problem your are looking at (without using Caliburn.Mirco?).

The question I find not answered in this issue is the AvalonDock API that you are intending to use?
And what of that part is not working as indended? If you end up looking at a demo app in this repro it might be best to manipulate it such that it will only simulate the usage of Caliburn.Micro without actually using it such that we can concentrate on the actual issue at hand?

Thanks for the response. I am pretty busy right now, but my plan is to test Caliburn 3.2 as well as the Xceed version of AvalonDock and see if the bug still occurs. Depending on the results, I'll build the issue project with AvalonDock as a project reference so I can step into the source code.

In the issue project, I'm binding against Caliburn BindableCollection<T> Items that inherits from System.Collections.ObjectModel.Collection<T>. Possibly the notifications from INotifyCollectionChanged are affecting AvalonDock.

When I created the test project, I wasn't exactly sure which component caused the bug (I thought it was going to be Caliburn). I have been using AvalonDock since 2013 through the Gemini WPF project (somewhat similar to EDI). When I put AvalonDock side-by-side to a TabControl, it does appear the bug is in this version/fork of AvalonDock. I didn't see it with the Xceed versions, but I hadn't updated or tested it recently either.

The Caliburn CanCloseAsync method is new in the 4.0 branch. The previous version was synchronous. Both versions allow the programmer to process logic before closing the view/document/screen. In my case, I prompt the user to save (yes, no, cancel) where cancel stops the close and leaves the document open. But, I only prompt the user if the document is "dirty". So many documents will not prompt the user because they have not changed.

In the test app, if there is a mix of changed and unchanged documents, all document disappear in AvalonDock, but in the TabControl only the cancelled close documents remain. I haven't fully tested, but it appears that if at least one document closes, then they all disappear. I'll try to figure out more later.

I just downgraded to Caliburn.Micro 3.2 and the issue does not occur (using the synchronous close logic).

So I'm not sure where the bug actually occurs, because the TabControl works with the asynchronous close.

I'll test the Xceed libraries for comparison.

I just tested Xceed.Products.Wpf.Toolkit.AvalonDock 4.0.20315.13310 and the issue occurred. So the likely culprit is Caliburn 4.0. It is still strange since the TabControl behaves as expected. I'd still like to debug the AvalonDock source code, but that will take more time and I'm not sure when I'll get to it.

Apparently in Caliburn 4.0, the BindableCollection raises multiple CollectionChanged events with NotifyCollectionChangedEventArgs.

  • For each view closed, a Action=Remove event
  • However, the final event is: Action=Reset, OldItems=null, OldStartingIndex=-1, NewItems=null, NewStartingIndex=-1

I assume AvalonDock assumes this means there are no items and closes all documents. I haven't had a chance to look at the AvalonDock source code or debug though.

But since the TabControl works, I assume an alternate assumption is to iterate through the collection and check what it actually contains.

Based on net searches, at one time Reset meant the collection was cleared; however now the documentation just says "the content of the collection has changed dramatically".

NotifyCollectionChangedAction
dotnet/dotnet-api-docs#3253

Looking at the source code, I believe this is the relevant code:

if (e.Action == NotifyCollectionChangedAction.Reset)

			if (e.Action == NotifyCollectionChangedAction.Reset)
			{
				//NOTE: I'm going to clear every document present in layout but
				//some documents may have been added directly to the layout, for now I clear them too
				var documentsToRemove = Layout.Descendents().OfType<LayoutDocument>().ToArray();
				foreach (var documentToRemove in documentsToRemove)
				{
					(documentToRemove.Parent as ILayoutContainer).RemoveChild(
						documentToRemove);
					RemoveViewFromLogicalChild(documentToRemove);
				}
			}

There is a similar block of code for LayoutAnchorable later in the file.

Just for reference, here is the sequence of events occurring in the issue project:

AViewModel.CanCloseAsync: close=True
BViewModel.CanCloseAsync: close=False
CViewModel.CanCloseAsync: close=True
CViewModel.OnDeactivateAsync: close=True
MainWindowViewModel.Items_CollectionChanged: Action=Remove, OldItems=[CViewModel], OldStartingIndex=2, NewItems=null, NewStartingIndex=-1
AViewModel.OnDeactivateAsync: close=True
MainWindowViewModel.Items_CollectionChanged: Action=Reset, OldItems=null, OldStartingIndex=-1, NewItems=null, NewStartingIndex=-1
BViewModel.OnDeactivateAsync: close=False

So I think there is indeed a bug in AvalonDock and how it handles Reset. But I think Caliburn changed the CloseStrategy in 4.0, and that is how I noticed the bug.

I came up with the following and it appears to fix the bug - in DockingManager.cs:2076

    if (e.Action == NotifyCollectionChangedAction.Reset)
    {
        //NOTE: Previous implementations cleared all documents from the layout. The current
        //guidance is that the collection has changed signficantly, so only remove the
        //documents that are no longer in the DocumentSource.
        var documentsThatRemain = new HashSet<object>(DocumentsSource.Cast<object>());
        var documentsToRemove = Layout.Descendents()
                                      .OfType<LayoutDocument>()
                                      .Where(x => !documentsThatRemain.Contains(x.Content))
                                      .ToArray();
        foreach (var documentToRemove in documentsToRemove)
        {
            (documentToRemove.Parent as ILayoutContainer).RemoveChild(
                documentToRemove);
            RemoveViewFromLogicalChild(documentToRemove);
        }
    }

I had to use the HashSet constructor instead of the ToHashSet extension method because it doesn't exist in net4. I am also concerned about the default EqualityComparer for HashSet. I assume it will call GetHashCode where I believe ReferenceEquals would be the desired comparer. So it might be necessary to use something like ReferenceEqualityComparer.

There are several other uses of System.Collections.Specialized.NotifyCollectionChangedAction.Reset. I haven't looked at them closely though they should be reevaluated.

if (e.Action == NotifyCollectionChangedAction.Reset)

if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset)

if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset)

Hi Ryan, thanks for the detailled analysis - I think your suggested change looks good but I am completely lost when it comes to testing it since I am normally not using this but that's of course no excuse for keeping a bug :-)

I understand that the:

  • 1st reference (Line 2076 in DockingManager) is invoked when the collection of Documents Reset and the
  • 2nd reference (Line 2272 in DockingManager) is invoked when the collection of Anchorables is Reset

So, for consistencies sake its probably best to handle both events the same way unless we can come up with an excuse not to (and I cannot see a reason for keeping this different). The change would just feel much better for me if I had a test case based on the test clients in the repo - so, I better understand when these methods are invoked - and I think we should state a description of that in front of either method - can you suggest something here?

For the last two references I am even less sure. I am thinking why fix something that's not broken? So, unless you can come up with a test case + Bug description that justifies a change I'd rather wait until we find a reason for this change.

So, in summary, I think we should:

  1. Apply the change you suggest to the 2 methods referenced in DockingManager.
  2. If you know a test case (using your sample or a unit test or more simplified test client) and a good description for both methods I'd be happy to test and would like to see the description in front of those methods.

Beyond this I am not sure about changing more - what do you think?

I think your proposal looks good. I'll try to create a test or two to make sure things actually work. First I need to figure out how to emulate the BindableCollection Reset. Browsing the Caliburn code base, there is an explicit Refresh method as well as AddRange and RemoveRange (that is the method used in my issue) that generate the Reset notification.

Another site mentioned that sorting the collection could generate a Reset, although I'm not sure how AvalonDock should behave if the collection was sorted. At the moment, I would recommend just handling the Reset as proposed and not do any reordering of LayoutDocument/Anchorable items.

I am busy on other work right now, but I can probably get a pull request with the fix and test cases out sometime next week.

This reminds me of a custom ObservableRangeCollection I've been using somewhere else. Maybe we can use something like this (with a custom method) to implement a unit test?