From ddfd0ae1e4c616994c01c1007a562a665d38e2c1 Mon Sep 17 00:00:00 2001 From: nalywa Date: Sun, 16 Nov 2025 10:34:15 -0500 Subject: [PATCH 1/2] Fix post-sort index tracking in RBTree insertion sort When using insertion sort to restore ListCollectionView sort order when live sorting is in effect, ensure that the new index (after sort) is resolved as a global index within the entire tree rather than a local index within a RBNode subtree. Fix https://github.com/dotnet/wpf/issues/11257 --- .../MS/Internal/Data/RBNode.cs | 1 + .../Windows/Data/ListCollectionViewTests.cs | 79 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/Data/RBNode.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/Data/RBNode.cs index 51f397ca018..0217c4973ce 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/Data/RBNode.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/Data/RBNode.cs @@ -290,6 +290,7 @@ protected RBFinger LocateItem(RBFinger finger, Comparison comparison) if (startingNode.LeftChild != null) { RBFinger newFinger = startingNode.LeftChild.Find(x, comparison); + newFinger.Index += nodeIndex - startingNode.LeftSize; // Translate from subtree index to tree index if (newFinger.Offset == newFinger.Node.Size) newFinger = new RBFinger() { Node = newFinger.Node.GetSuccessor(), Offset = 0, Index = newFinger.Index }; return newFinger; diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs new file mode 100644 index 00000000000..4cebbf31cbc --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace System.Windows.Data; + +public sealed class ListCollectionViewTests +{ + public sealed class TestItem : INotifyPropertyChanged + { + public int Value + { + get => field; + set + { + if (field != value) + { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } + + [WpfFact] + public async Task LiveSorting_INotifyCollectionChanged_Consistency() + { + var random = new Random(0); + + var sourceList = new ObservableCollection( + Enumerable.Range(0, 1000).Select(_ => new TestItem { Value = random.Next() })); + + var collectionView = new ListCollectionView(sourceList) + { + IsLiveSorting = true, + SortDescriptions = { new SortDescription(nameof(TestItem.Value), ListSortDirection.Ascending) }, + LiveSortingProperties = { nameof(TestItem.Value) } + }; + + var observedList = new List(collectionView.Cast()); + ((INotifyCollectionChanged)collectionView).CollectionChanged += (_, a) => + { + Assert.True(a.Action == NotifyCollectionChangedAction.Move); + Assert.True(a.OldItems != null); + Assert.True(a.OldStartingIndex >= 0 && a.OldStartingIndex + a.OldItems.Count <= observedList.Count); + int idx = a.OldStartingIndex; + for (int i = 0; i < a.OldItems.Count; i++) + { + Assert.Same(observedList[idx + i], a.OldItems[i]); + } + observedList.RemoveRange(a.OldStartingIndex, a.OldItems.Count); + Assert.True(a.NewItems != null); + Assert.True(a.OldItems.Cast().SequenceEqual(a.NewItems.Cast())); + idx = a.NewStartingIndex; + for (int i = 0; i < a.NewItems.Count; i++) + { + Assert.Equal(idx + i, collectionView.IndexOf(a.NewItems[i])); + } + observedList.InsertRange(a.NewStartingIndex, a.NewItems.Cast()); + }; + + for (int i = 0; i < 10; i++) + { + foreach (var item in sourceList) + { + item.Value = random.Next(); + } + await Dispatcher.CurrentDispatcher.InvokeAsync(() => { }, DispatcherPriority.DataBind); // Trigger RestoreLiveShaping() + } + } +} From bd22dc0e9d921f6199475d9efe416feb855b4e3c Mon Sep 17 00:00:00 2001 From: nalywa Date: Tue, 18 Nov 2025 00:16:20 +0000 Subject: [PATCH 2/2] Restrict ListCollectionViewTests.TestItem visibility --- .../System/Windows/Data/ListCollectionViewTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs index 4cebbf31cbc..e85e1b68b2c 100644 --- a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/System/Windows/Data/ListCollectionViewTests.cs @@ -12,7 +12,7 @@ namespace System.Windows.Data; public sealed class ListCollectionViewTests { - public sealed class TestItem : INotifyPropertyChanged + private sealed class TestItem : INotifyPropertyChanged { public int Value {