From 0b8ae0bce761ad13708bf3ae553a00bbf2217c41 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 16 May 2026 22:32:15 +0200 Subject: [PATCH] Prune navigation history when assemblies are removed Ports the WPF assemblyList_CollectionChanged history-prune from ILSpy/AssemblyTree/AssemblyTreeModel.cs to the Avalonia DockWorkspace.OnAssemblyListChanged handler. NavigationHistory already exposed the RemoveAll(Predicate) primitive; the caller wiring was the missing piece. Without it, a tree-row click after removing an assembly walked through NavigationEntry.DisplayText on a stale TreeNodeEntry, which hit MemberReferenceTreeNode.Signature -> Language.EntityToString -> ILAmbience.ConvertSymbol and NRE'd on a now-null ParentModule. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ICSharpCode.Decompiler/IL/ILAmbience.cs | 16 ++++++++-- ILSpy/Docking/DockWorkspace.cs | 41 ++++++++++++++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/ICSharpCode.Decompiler/IL/ILAmbience.cs b/ICSharpCode.Decompiler/IL/ILAmbience.cs index 071145d46..c750ff722 100644 --- a/ICSharpCode.Decompiler/IL/ILAmbience.cs +++ b/ICSharpCode.Decompiler/IL/ILAmbience.cs @@ -53,11 +53,23 @@ namespace ICSharpCode.Decompiler.IL void ConvertSymbol(StringWriter writer, ISymbol symbol) { - var metadata = (symbol as IEntity)?.ParentModule!.MetadataFile?.Metadata; - var token = (symbol as IEntity)?.MetadataToken ?? default; + var entity = symbol as IEntity; + var metadata = entity?.ParentModule?.MetadataFile?.Metadata; + var token = entity?.MetadataToken ?? default; var output = new PlainTextOutput(writer); + // When the symbol's owning module has been torn down (stale entity pinned in + // navigation history after the assembly was unloaded/reloaded), the rest of + // the switch can't read its metadata reader. Fall back to the symbol's name + // so callers still get sensible display text — the NRE would otherwise + // surface up through every consumer that formats a stale history entry. + if (metadata == null) + { + writer.Write(symbol?.Name ?? string.Empty); + return; + } + switch (symbol) { case IField f: diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index edc5200dd..b10385128 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -183,12 +183,24 @@ namespace ILSpy.Docking void OnAssemblyListChanged(object? sender, ILSpy.Util.CurrentAssemblyListChangedEventArgs e) { var inner = e.Inner; + + // On Reset (assembly list wholesale-cleared), drop ALL history — every entry is + // stale by definition. Mirrors WPF's assemblyList_CollectionChanged. + if (inner.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + { + PruneHistoryAfterAssemblyListChange(removed: null); + return; + } + if (inner.OldItems is not { Count: > 0 } oldItems) return; var removed = new HashSet( oldItems.OfType()); if (removed.Count == 0) return; + + PruneHistoryAfterAssemblyListChange(removed); + if (factory.Documents?.VisibleDockables is not { } visible) return; @@ -212,14 +224,29 @@ namespace ILSpy.Docking if (!anyAlive) factory.CloseDockable(dockable); } + } - // TODO: layout persistence (load on startup, save on exit). DockSerializer.SystemTextJson - // trips System.Text.Json's MaxDepth=64 limit on our layout because Dock's JsonConverterList - // calls JsonSerializer.Serialize per list element, which doesn't preserve the writer's - // reference-tracking state between calls — so Owner/Factory back-refs aren't deduplicated as - // $ref. Options when revisiting: try Dock.Serializer.Newtonsoft (better cycle handling), - // or build a custom JsonSerializerOptions with MaxDepth raised + replicate Dock's internal - // polymorphic resolver. + // Drops history entries whose tree node lives inside a now-removed assembly. With + // == null, drops everything (used for the Reset action). + // Without this, Back/Forward would surface entries whose Node reference has been + // detached from the live tree, and formatting their DisplayText routes through + // language-specific formatters that NRE on a stale IEntity.ParentModule. + void PruneHistoryAfterAssemblyListChange(HashSet? removed) + { + if (removed == null) + { + history.RemoveAll(_ => true); + } + else + { + history.RemoveAll(entry => + entry is Navigation.TreeNodeEntry t + && t.Node.AncestorsAndSelf() + .OfType() + .Any(a => removed.Contains(a.LoadedAssembly))); + } + NavigateBackCommand.NotifyCanExecuteChanged(); + NavigateForwardCommand.NotifyCanExecuteChanged(); } void OnDocumentMembershipChanged(object? sender, EventArgs e) => UpdateLastDocumentCanClose();