From 065c9098fb7912e420db7536de4a14b42cc678e9 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 1 Sep 2026 18:09:46 +0200 Subject: [PATCH] Cover tree mutation under a scrolled, virtualized SharpTreeView Issue #3290 reports a NullReferenceException inside SharpTreeNode.GetNodeByVisibleIndex, reached from a WPF virtualizing panel's measure pass. The frame is still live: the Avalonia SharpTreeView binds the TreeFlattener straight to ItemsSource, so Avalonia's virtualization indexes the same flat-list walk on every measure. Driving the model directly shows the indexer cannot walk off the end on one thread: TreeFlattener.Count and GetNodeByVisibleIndex read the same totalListLength fields with nothing in between, so a stale index becomes an ArgumentOutOfRangeException and never a null dereference. Randomized sweeps over insert/remove/expand/collapse/hide/reparent found no state where the two disagree. Only a mutation concurrent with the descent reproduces the reported frame, and every tree mutation in the app is marshalled to the UI thread. These tests pin the interleaving that the port changed: the flattened list shrinking underneath a realized index range, and a lazy subtree loading and reloading while scrolled. The first asserts the panel is actually virtualizing, so it cannot quietly degrade into a non-virtualized run that proves nothing. Assisted-by: Claude:claude-opus-5:Claude Code --- ILSpy.Tests/Controls/SharpTreeViewTests.cs | 121 +++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/ILSpy.Tests/Controls/SharpTreeViewTests.cs b/ILSpy.Tests/Controls/SharpTreeViewTests.cs index 80c860eb7..ba87155e1 100644 --- a/ILSpy.Tests/Controls/SharpTreeViewTests.cs +++ b/ILSpy.Tests/Controls/SharpTreeViewTests.cs @@ -151,4 +151,125 @@ public class SharpTreeViewTests c.IsSelected.Should().BeTrue(); a.IsSelected.Should().BeFalse("moving the selection clears the old node's flag"); } + + /// The number of rows the flattener should expose for a tree rooted in + /// when the root itself is not shown. + static int VisibleRowCount(SharpTreeNode root) + { + int count = 0; + void Walk(SharpTreeNode node) + { + foreach (SharpTreeNode child in node.Children) + { + if (child.IsHidden) + continue; + count++; + if (child.IsExpanded) + Walk(child); + } + } + Walk(root); + return count; + } + + static void AssertEveryRowResolves(SharpTreeView tree, SharpTreeNode root, string because) + { + tree.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + int expected = VisibleRowCount(root); + tree.ItemCount.Should().Be(expected, because); + for (int i = 0; i < expected; i++) + tree.ItemsView[i].Should().NotBeNull($"row {i} must resolve after {because}"); + } + + /// + /// Collapsing and removing nodes above a scrolled viewport shrinks the flattened list under + /// the virtualizing panel's realized index range. The panel must not be left indexing rows + /// that no longer exist. + /// + [AvaloniaTest] + public void Shrinking_The_Tree_Above_A_Scrolled_Viewport_Keeps_Every_Row_Resolvable() + { + var groups = Enumerable.Range(0, 200) + .Select(i => new TestNode($"g{i}", Enumerable.Range(0, 5) + .Select(j => new TestNode($"g{i}_{j}")).ToArray())) + .ToArray(); + var root = new TestNode("root", groups); + var tree = new SharpTreeView { ShowRoot = false, Root = root }; + var window = new Window { Content = tree, Width = 300, Height = 400 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + foreach (var group in groups) + group.IsExpanded = true; + AssertEveryRowResolves(tree, root, "expanding every group"); + tree.GetRealizedContainers().Count().Should() + .BeLessThan(tree.ItemCount, "the panel must be virtualizing, or this test proves nothing"); + + // Park the realized range far from index 0, with a live selection inside it. + tree.SelectedItem = groups[^1].Children[^1]; + tree.ScrollIntoView(tree.ItemCount - 1); + AssertEveryRowResolves(tree, root, "scrolling to the last row"); + + for (int i = 0; i < 190; i++) + groups[i].IsExpanded = false; + AssertEveryRowResolves(tree, root, "collapsing 190 groups above the viewport"); + + for (int i = 0; i < 150; i++) + root.Children.RemoveAt(0); + AssertEveryRowResolves(tree, root, "removing 150 groups above the viewport"); + + tree.ScrollIntoView(0); + AssertEveryRowResolves(tree, root, "scrolling back to the top"); + } + + /// + /// A lazily loaded node replaces its placeholder with real children while the tree is + /// scrolled: the row count grows and shrinks in the same gesture. + /// + [AvaloniaTest] + public void Lazy_Loading_Under_A_Scrolled_Viewport_Keeps_Every_Row_Resolvable() + { + var lazy = Enumerable.Range(0, 100).Select(i => new LazyNode($"l{i}", 7)).ToArray(); + var root = new TestNode("root"); + foreach (var node in lazy) + root.Children.Add(node); + var tree = new SharpTreeView { ShowRoot = false, Root = root }; + var window = new Window { Content = tree, Width = 300, Height = 400 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + AssertEveryRowResolves(tree, root, "the initial collapsed list"); + + tree.ScrollIntoView(tree.ItemCount - 1); + AssertEveryRowResolves(tree, root, "scrolling to the last row"); + + foreach (var node in lazy) + { + node.IsExpanded = true; + AssertEveryRowResolves(tree, root, $"lazily expanding {node.Text}"); + } + + foreach (var node in lazy) + node.ReloadChildren(); + AssertEveryRowResolves(tree, root, "reloading every lazy subtree in place"); + } + + sealed class LazyNode : SharpTreeNode + { + readonly string text; + readonly int childCount; + public LazyNode(string text, int childCount) + { + this.text = text; + this.childCount = childCount; + LazyLoading = true; + } + public override object Text => text; + protected override void LoadChildren() + { + for (int i = 0; i < childCount; i++) + Children.Add(new TestNode($"{text}_{i}")); + } + } }