From 4585be8de99f9fe3d76aa028365265abfcc15409 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 5 Jun 2026 20:43:24 +0200 Subject: [PATCH] Raise atomic ranged events from the tree flattener NodesInserted/NodesRemoved reported a multi-node change (a node plus its visible descendants -- a contiguous run) as a sequence of single-item Add/Remove events, but the underlying tree updates Count by the WHOLE run before they fire. A consumer that reconciles the change one item at a time and re-indexes the source mid-sequence then reads against a Count that has already dropped by the full run: Avalonia's SelectionModel does exactly this (it re-reads SelectedItems while handling each Remove), indexing past the end and throwing ArgumentOutOfRangeException out of TreeFlattener's indexer whenever a selection was live during a bulk reshape (e.g. toggling UseNestedNamespaceNodes). It only surfaced under full-suite timing, which is why it read as flaky. Raise one ranged event for the whole run instead, so the notification matches the tree's state in a single step and Count/indices stay in agreement. The indexer keeps throwing on genuine out-of-range access. Avalonia 12.4's virtualization and selection handle ranged Add/Remove (every expand/collapse exercises it); full headless suite is green. --- ICSharpCode.ILSpyX/TreeView/TreeFlattener.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ICSharpCode.ILSpyX/TreeView/TreeFlattener.cs b/ICSharpCode.ILSpyX/TreeView/TreeFlattener.cs index f32d2c078..67ae9200d 100644 --- a/ICSharpCode.ILSpyX/TreeView/TreeFlattener.cs +++ b/ICSharpCode.ILSpyX/TreeView/TreeFlattener.cs @@ -52,24 +52,28 @@ namespace ICSharpCode.ILSpyX.TreeView CollectionChanged?.Invoke(this, e); } + // A node plus its visible descendants form one contiguous run in the flattened list, and the + // underlying tree's Count is updated by the WHOLE run before these fire. Raise a single ranged + // event so the notification matches the tree: Count and indices stay in agreement for a + // consumer that reconciles the change in one step. Per-node events instead leave the source + // already fully resized while the consumer is still mid-sequence, so one that re-indexes during + // reconciliation (Avalonia's SelectionModel re-reading SelectedItems) reads past the end. public void NodesInserted(int index, IEnumerable nodes) { if (!includeRoot) index--; - foreach (SharpTreeNode node in nodes) - { - RaiseCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, node, index++)); - } + IList list = nodes as IList ?? new List(nodes); + if (list.Count > 0) + RaiseCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, list, index)); } public void NodesRemoved(int index, IEnumerable nodes) { if (!includeRoot) index--; - foreach (SharpTreeNode node in nodes) - { - RaiseCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, node, index)); - } + IList list = nodes as IList ?? new List(nodes); + if (list.Count > 0) + RaiseCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, list, index)); } public void Stop()