From b47360ebc4e4668e8efdb5224a2192bc8d67b6cf Mon Sep 17 00:00:00 2001 From: fuffc Date: Mon, 13 Jul 2026 21:49:09 +0200 Subject: [PATCH 1/5] Fix System.Text.Json Owner reference-cycle handling The per-item JsonConverterList restarted reference tracking on every element, breaking $id/$ref across list boundaries. Replace it with a JsonTypeInfo resolver modifier that only overrides list instantiation, leaving the built-in enumerable path in control of the whole graph. --- .../DockListTypeInfoModifier.cs | 32 ++++++++ .../DockSerializerOptionsFactory.cs | 3 +- .../JsonConverterFactoryList.cs | 46 ----------- .../JsonConverterList.cs | 74 ------------------ .../DockLayoutReferencePreservationTests.cs | 77 +++++++++++++++++++ 5 files changed, 110 insertions(+), 122 deletions(-) create mode 100644 src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs delete mode 100644 src/Dock.Serializer.SystemTextJson/JsonConverterFactoryList.cs delete mode 100644 src/Dock.Serializer.SystemTextJson/JsonConverterList.cs create mode 100644 tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs diff --git a/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs b/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs new file mode 100644 index 000000000..166af119e --- /dev/null +++ b/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs @@ -0,0 +1,32 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization.Metadata; + +namespace Dock.Serializer.SystemTextJson; + +/// +/// Resolver modifier that makes properties deserialize into a +/// configured concrete list type without replacing the built-in enumerable path. +/// +internal static class DockListTypeInfoModifier +{ + public static void Apply(JsonTypeInfo typeInfo, Type listType) + { + if (typeInfo.Kind != JsonTypeInfoKind.Enumerable) + { + return; + } + + var type = typeInfo.Type; + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(IList<>)) + { + return; + } + + var elementType = type.GetGenericArguments()[0]; + var concreteListType = listType.MakeGenericType(elementType); + typeInfo.CreateObject = () => Activator.CreateInstance(concreteListType)!; + } +} diff --git a/src/Dock.Serializer.SystemTextJson/DockSerializerOptionsFactory.cs b/src/Dock.Serializer.SystemTextJson/DockSerializerOptionsFactory.cs index 718cc44d2..6f9b93722 100644 --- a/src/Dock.Serializer.SystemTextJson/DockSerializerOptionsFactory.cs +++ b/src/Dock.Serializer.SystemTextJson/DockSerializerOptionsFactory.cs @@ -37,10 +37,9 @@ public static JsonSerializerOptions Create(Type listType, IJsonTypeInfoResolver ReferenceHandler = ReferenceHandler.Preserve, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, - TypeInfoResolver = typeInfoResolver + TypeInfoResolver = typeInfoResolver.WithAddedModifier(typeInfo => DockListTypeInfoModifier.Apply(typeInfo, listType)) }; - options.Converters.Add(new JsonConverterFactoryList(listType)); return options; } } diff --git a/src/Dock.Serializer.SystemTextJson/JsonConverterFactoryList.cs b/src/Dock.Serializer.SystemTextJson/JsonConverterFactoryList.cs deleted file mode 100644 index 411e6bbab..000000000 --- a/src/Dock.Serializer.SystemTextJson/JsonConverterFactoryList.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Wiesław Šoltés. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Dock.Serializer.SystemTextJson; - -/// -/// JSON converter factory for using a custom list type. -/// -public class JsonConverterFactoryList : JsonConverterFactory -{ - private readonly Type _listType; - - /// - /// Initializes a new instance of the class. - /// - /// The generic list type to instantiate. - public JsonConverterFactoryList(Type listType) - { - _listType = listType; - } - - /// - public override bool CanConvert(Type typeToConvert) - => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(IList<>); - - /// - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - Debug.Assert(typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(IList<>)); - - var elementType = typeToConvert.GetGenericArguments()[0]; - var converterType = typeof(JsonConverterList<>).MakeGenericType(elementType); - return (JsonConverter)Activator.CreateInstance( - converterType, - BindingFlags.Instance | BindingFlags.Public, - binder: null, - args: new object[] { _listType }, - culture: null)!; - } -} diff --git a/src/Dock.Serializer.SystemTextJson/JsonConverterList.cs b/src/Dock.Serializer.SystemTextJson/JsonConverterList.cs deleted file mode 100644 index 7710b4dab..000000000 --- a/src/Dock.Serializer.SystemTextJson/JsonConverterList.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Wiesław Šoltés. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Dock.Serializer.SystemTextJson; - -/// -/// JSON converter for using a custom list type. -/// -/// The element type. -public class JsonConverterList : JsonConverter> -{ - private readonly Type _listType; - - /// - /// Initializes a new instance of the class. - /// - /// The generic list type to instantiate. - public JsonConverterList(Type listType) - { - _listType = listType; - } - - /// - public override IList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartArray) - { - throw new JsonException(); - } - reader.Read(); - - IList list = CreateList(); - - while (reader.TokenType != JsonTokenType.EndArray) - { - var item = JsonSerializer.Deserialize(ref reader, options)!; - list.Add(item); - reader.Read(); - } - - return list; - } - - /// - public override void Write(Utf8JsonWriter writer, IList value, JsonSerializerOptions options) - { - writer.WriteStartArray(); - foreach (var item in value) - { - JsonSerializer.Serialize(writer, item, options); - } - writer.WriteEndArray(); - } - - private IList CreateList() - { - if (_listType == typeof(List<>)) - { - return new List(); - } - - if (_listType == typeof(ObservableCollection<>)) - { - return new ObservableCollection(); - } - - return (IList)Activator.CreateInstance(_listType.MakeGenericType(typeof(T)))!; - } -} diff --git a/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs new file mode 100644 index 000000000..5f2ccdb95 --- /dev/null +++ b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Dock.Model.Core; +using Dock.Model.Inpc; +using Dock.Model.Inpc.Controls; +using Xunit; +using NewtonsoftDockSerializer = Dock.Serializer.DockSerializer; +using SystemTextJsonDockSerializer = Dock.Serializer.SystemTextJson.DockSerializer; + +namespace Dock.Serializer.UnitTests; + +public class DockLayoutReferencePreservationTests +{ + public static IEnumerable ReferencePreservingSerializerFactories => + [ + new object[] { "Newtonsoft", (Func)(() => new NewtonsoftDockSerializer()) }, + new object[] { "SystemTextJson", (Func)(() => new SystemTextJsonDockSerializer()) } + ]; + + private sealed class NonClosingMemoryStream : MemoryStream + { + protected override void Dispose(bool disposing) + { + if (disposing) + { + Flush(); + } + } + } + + [Theory] + [MemberData(nameof(ReferencePreservingSerializerFactories))] + public void SaveLoad_Roundtrip_PreservesActiveDockableIdentity(string serializerName, Func factory) + { + var serializer = factory(); + var layout = CreateLayout(); + + using var stream = new NonClosingMemoryStream(); + serializer.Save(stream, layout); + Assert.True(stream.Length > 0, $"{serializerName} did not write any data."); + + stream.Position = 0; + var loaded = serializer.Load(stream); + + Assert.NotNull(loaded); + var documentDock = Assert.IsType(loaded!.VisibleDockables![0]); + Assert.NotNull(documentDock.VisibleDockables); + Assert.Same(documentDock.ActiveDockable, documentDock.VisibleDockables![0]); + } + + private static RootDock CreateLayout() + { + var factory = new Factory(); + + var documentDock = new DocumentDock + { + Id = "DocumentDock", + Title = "Documents", + VisibleDockables = factory.CreateList(), + }; + + var root = (RootDock)factory.CreateRootDock(); + root.Id = "Root"; + root.Title = "Root"; + root.VisibleDockables = factory.CreateList(documentDock); + root.DefaultDockable = documentDock; + + factory.InitLayout(root); + + var document = new Document { Id = "Doc1", Title = "Document 1" }; + factory.AddDockable(documentDock, document); + factory.SetActiveDockable(document); + + return root; + } +} From c321ea0f70f86091c49fe62b8f41fba307246f58 Mon Sep 17 00:00:00 2001 From: fuffc Date: Mon, 13 Jul 2026 21:50:10 +0200 Subject: [PATCH 2/5] Fix Xml/Yaml StackOverflowException on Owner reference cycles ListTypeConverter walked the deserialized tree with no cycle guard, recursing forever once the Owner back-reference was restored. Add a reference-equality visited set; the walk is idempotent. --- src/Dock.Serializer.Xml/ListTypeConverter.cs | 13 ++++++++++--- src/Dock.Serializer.Yaml/ListTypeConverter.cs | 13 ++++++++++--- .../DockLayoutReferencePreservationTests.cs | 6 +++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Dock.Serializer.Xml/ListTypeConverter.cs b/src/Dock.Serializer.Xml/ListTypeConverter.cs index e552248bf..e00846b3a 100644 --- a/src/Dock.Serializer.Xml/ListTypeConverter.cs +++ b/src/Dock.Serializer.Xml/ListTypeConverter.cs @@ -10,14 +10,21 @@ namespace Dock.Serializer.Xml; internal static class ListTypeConverter { public static void Convert(object? obj, Type listType) + { + Convert(obj, listType, new HashSet(ReferenceEqualityComparer.Instance)); + } + + private static void Convert(object? obj, Type listType, HashSet visited) { if (obj is null) return; + if (!visited.Add(obj)) + return; if (obj is IEnumerable enumerable && obj is not string) { foreach (var item in enumerable) { - Convert(item, listType); + Convert(item, listType, visited); } } var type = obj.GetType(); @@ -39,13 +46,13 @@ public static void Convert(object? obj, Type listType) } foreach (var item in list) { - Convert(item, listType); + Convert(item, listType, visited); } property.SetValue(obj, list); } else { - Convert(value, listType); + Convert(value, listType, visited); } } } diff --git a/src/Dock.Serializer.Yaml/ListTypeConverter.cs b/src/Dock.Serializer.Yaml/ListTypeConverter.cs index 84d1f331c..1828883bc 100644 --- a/src/Dock.Serializer.Yaml/ListTypeConverter.cs +++ b/src/Dock.Serializer.Yaml/ListTypeConverter.cs @@ -10,14 +10,21 @@ namespace Dock.Serializer.Yaml; internal static class ListTypeConverter { public static void Convert(object? obj, Type listType) + { + Convert(obj, listType, new HashSet(ReferenceEqualityComparer.Instance)); + } + + private static void Convert(object? obj, Type listType, HashSet visited) { if (obj is null) return; + if (!visited.Add(obj)) + return; if (obj is IEnumerable enumerable && obj is not string) { foreach (var item in enumerable) { - Convert(item, listType); + Convert(item, listType, visited); } } var type = obj.GetType(); @@ -39,13 +46,13 @@ public static void Convert(object? obj, Type listType) } foreach (var item in list) { - Convert(item, listType); + Convert(item, listType, visited); } property.SetValue(obj, list); } else { - Convert(value, listType); + Convert(value, listType, visited); } } } diff --git a/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs index 5f2ccdb95..4189d8ab3 100644 --- a/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs +++ b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs @@ -4,6 +4,8 @@ using Dock.Model.Core; using Dock.Model.Inpc; using Dock.Model.Inpc.Controls; +using Dock.Serializer.Xml; +using Dock.Serializer.Yaml; using Xunit; using NewtonsoftDockSerializer = Dock.Serializer.DockSerializer; using SystemTextJsonDockSerializer = Dock.Serializer.SystemTextJson.DockSerializer; @@ -15,7 +17,9 @@ public class DockLayoutReferencePreservationTests public static IEnumerable ReferencePreservingSerializerFactories => [ new object[] { "Newtonsoft", (Func)(() => new NewtonsoftDockSerializer()) }, - new object[] { "SystemTextJson", (Func)(() => new SystemTextJsonDockSerializer()) } + new object[] { "SystemTextJson", (Func)(() => new SystemTextJsonDockSerializer()) }, + new object[] { "Yaml", (Func)(() => new DockYamlSerializer()) }, + new object[] { "Xml", (Func)(() => new DockXmlSerializer()) } ]; private sealed class NonClosingMemoryStream : MemoryStream From 6d578f89c0ead6dd0d722739c746027c83243c05 Mon Sep 17 00:00:00 2001 From: fuffc Date: Mon, 13 Jul 2026 21:51:03 +0200 Subject: [PATCH 3/5] Fix Protobuf Owner cycle crash and ActiveDockable duplication protobuf-net 3.x cannot preserve references, so Owner/OriginalOwner are excluded from the wire. ListTypeConverter now rebuilds Owner from tree containment and reconciles ActiveDockable/DefaultDockable/FocusedDockable by Id, and picks up the Xml/Yaml visited-set cycle guard. --- .../ListTypeConverter.cs | 117 +++++++++++++++++- .../ProtobufDockSerializer.cs | 14 +++ .../DockLayoutReferencePreservationTests.cs | 64 +++++++++- 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/src/Dock.Serializer.Protobuf/ListTypeConverter.cs b/src/Dock.Serializer.Protobuf/ListTypeConverter.cs index d21be45d7..751966b02 100644 --- a/src/Dock.Serializer.Protobuf/ListTypeConverter.cs +++ b/src/Dock.Serializer.Protobuf/ListTypeConverter.cs @@ -4,20 +4,29 @@ using System.Collections; using System.Collections.Generic; using System.Reflection; +using Dock.Model.Controls; +using Dock.Model.Core; namespace Dock.Serializer.Protobuf; internal static class ListTypeConverter { public static void Convert(object? obj, Type listType) + { + Convert(obj, listType, new HashSet(ReferenceEqualityComparer.Instance)); + } + + private static void Convert(object? obj, Type listType, HashSet visited) { if (obj is null) return; + if (!visited.Add(obj)) + return; if (obj is IEnumerable enumerable && obj is not string) { foreach (var item in enumerable) { - Convert(item, listType); + Convert(item, listType, visited); } } var type = obj.GetType(); @@ -39,14 +48,116 @@ public static void Convert(object? obj, Type listType) } foreach (var item in list) { - Convert(item, listType); + Convert(item, listType, visited); } property.SetValue(obj, list); } else { - Convert(value, listType); + Convert(value, listType, visited); + } + } + + // protobuf-net cannot preserve references: reconcile the dockable aliases that came back + // as duplicates, and rebuild Owner (excluded from the wire) from tree containment. + if (obj is IDock dock) + { + Reconcile(dock, dock.ActiveDockable, v => dock.ActiveDockable = v); + Reconcile(dock, dock.DefaultDockable, v => dock.DefaultDockable = v); + Reconcile(dock, dock.FocusedDockable, v => dock.FocusedDockable = v); + + SetOwner(dock.VisibleDockables, dock); + + if (dock is IRootDock rootDock) + { + SetOwner(rootDock.HiddenDockables, rootDock); + SetOwner(rootDock.LeftPinnedDockables, rootDock); + SetOwner(rootDock.RightPinnedDockables, rootDock); + SetOwner(rootDock.TopPinnedDockables, rootDock); + SetOwner(rootDock.BottomPinnedDockables, rootDock); + + if (rootDock.PinnedDock is { } pinnedDock) + { + pinnedDock.Owner = rootDock; + } + + if (rootDock.Windows is not null) + { + foreach (var window in rootDock.Windows) + { + window.Owner = rootDock; + } + } + } + + if (dock is ISplitViewDock splitViewDock) + { + if (splitViewDock.PaneDockable is { } paneDockable) + { + paneDockable.Owner = dock; + } + + if (splitViewDock.ContentDockable is { } contentDockable) + { + contentDockable.Owner = dock; + } + } + } + } + + private static void SetOwner(IList? dockables, IDockable owner) + { + if (dockables is null) + { + return; + } + + foreach (var dockable in dockables) + { + dockable.Owner = owner; + } + } + + private static void Reconcile(IDock dock, IDockable? current, Action setter) + { + if (current is null || string.IsNullOrEmpty(current.Id)) + { + return; + } + + var canonical = FindById(dock.VisibleDockables, current.Id) + ?? (dock is IRootDock rootDock ? FindById(rootDock.HiddenDockables, current.Id) : null); + + if (canonical is not null && !ReferenceEquals(canonical, current)) + { + setter(canonical); + } + } + + private static IDockable? FindById(IList? dockables, string id) + { + if (dockables is null) + { + return null; + } + + IDockable? match = null; + foreach (var dockable in dockables) + { + if (dockable.Id != id) + { + continue; } + + if (match is not null) + { + // Ambiguous: more than one dockable shares this Id, so we cannot pick the alias safely. + return null; + } + + match = dockable; } + + return match; } } diff --git a/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs b/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs index db9947862..452698e44 100644 --- a/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs +++ b/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs @@ -353,6 +353,15 @@ private static void ConfigureClass(RuntimeTypeModel model, Type type) } } + private static bool IsBackReferenceProperty(PropertyInfo property) + { + // Owner/OriginalOwner close the reference cycles that protobuf-net 3.x cannot preserve + // (AsReference is obsolete-as-error), so they are excluded from the wire; ListTypeConverter + // rebuilds Owner from tree containment after deserializing. + return (property.Name == nameof(IDockable.Owner) || property.Name == nameof(IDockable.OriginalOwner)) + && typeof(IDockable).IsAssignableFrom(property.PropertyType); + } + private static IReadOnlyList GetSerializableProperties(Type type) { var properties = new List(); @@ -373,6 +382,11 @@ private static IReadOnlyList GetSerializableProperties(Type type) continue; } + if (IsBackReferenceProperty(property)) + { + continue; + } + properties.Add(property); } diff --git a/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs index 4189d8ab3..d3c5c586a 100644 --- a/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs +++ b/tests/Dock.Serializer.UnitTests/DockLayoutReferencePreservationTests.cs @@ -4,6 +4,8 @@ using Dock.Model.Core; using Dock.Model.Inpc; using Dock.Model.Inpc.Controls; +using Dock.Model.Inpc.Core; +using Dock.Serializer.Protobuf; using Dock.Serializer.Xml; using Dock.Serializer.Yaml; using Xunit; @@ -19,7 +21,8 @@ public class DockLayoutReferencePreservationTests new object[] { "Newtonsoft", (Func)(() => new NewtonsoftDockSerializer()) }, new object[] { "SystemTextJson", (Func)(() => new SystemTextJsonDockSerializer()) }, new object[] { "Yaml", (Func)(() => new DockYamlSerializer()) }, - new object[] { "Xml", (Func)(() => new DockXmlSerializer()) } + new object[] { "Xml", (Func)(() => new DockXmlSerializer()) }, + new object[] { "Protobuf", (Func)(() => new ProtobufDockSerializer()) } ]; private sealed class NonClosingMemoryStream : MemoryStream @@ -53,6 +56,65 @@ public void SaveLoad_Roundtrip_PreservesActiveDockableIdentity(string serializer Assert.Same(documentDock.ActiveDockable, documentDock.VisibleDockables![0]); } + [Fact] + public void SaveLoad_Roundtrip_Protobuf_RestoresOwnerStructurally() + { + // protobuf-net excludes Owner from the wire, so ListTypeConverter rebuilds it from + // tree containment on Load - no IFactory.InitLayout call required. + var serializer = new ProtobufDockSerializer(); + var layout = CreateLayout(); + + using var stream = new NonClosingMemoryStream(); + serializer.Save(stream, layout); + Assert.True(stream.Length > 0); + + stream.Position = 0; + var loaded = serializer.Load(stream); + + Assert.NotNull(loaded); + var documentDock = Assert.IsType(loaded!.VisibleDockables![0]); + Assert.Same(loaded, documentDock.Owner); + + var document = Assert.IsType(documentDock.VisibleDockables![0]); + Assert.Same(documentDock, document.Owner); + } + + [Fact] + public void SaveLoad_Roundtrip_Protobuf_RestoresOwnerForPinnedWindowsAndSplitView() + { + var factory = new Factory(); + + var toolDock = new ToolDock { Id = "ToolDock", VisibleDockables = factory.CreateList(new Tool { Id = "Tool" }) }; + var documentDock = new DocumentDock { Id = "DocumentDock", VisibleDockables = factory.CreateList(new Document { Id = "Doc" }) }; + var splitView = new SplitViewDock + { + Id = "SplitView", + PaneDockable = toolDock, + ContentDockable = documentDock, + VisibleDockables = factory.CreateList(), + }; + + var root = (RootDock)factory.CreateRootDock(); + root.Id = "Root"; + root.VisibleDockables = factory.CreateList(splitView); + root.LeftPinnedDockables = factory.CreateList(new Tool { Id = "PinnedTool" }); + root.Windows = factory.CreateList(new DockWindow { Id = "Window" }); + + var serializer = new ProtobufDockSerializer(); + using var stream = new NonClosingMemoryStream(); + serializer.Save(stream, root); + stream.Position = 0; + var loaded = serializer.Load(stream); + + Assert.NotNull(loaded); + var loadedSplitView = Assert.IsType(loaded!.VisibleDockables![0]); + Assert.Same(loaded, loadedSplitView.Owner); + Assert.Same(loadedSplitView, loadedSplitView.PaneDockable!.Owner); + Assert.Same(loadedSplitView, loadedSplitView.ContentDockable!.Owner); + Assert.Same(loaded, loaded.LeftPinnedDockables![0].Owner); + Assert.Same(loaded, loaded.Windows![0].Owner); + } + private static RootDock CreateLayout() { var factory = new Factory(); From c113f33cc37f89b48ba338f7b51be7ae68f7f9aa Mon Sep 17 00:00:00 2001 From: fuffc Date: Mon, 13 Jul 2026 21:51:44 +0200 Subject: [PATCH 4/5] Fix Protobuf "Unexpected sub-type" for custom Dockable subclasses protobuf-net dispatches through the CLR base-class chain, but subtype registration only considered the closest interface. Register each class under its nearest tracked base class too. --- .../ProtobufDockSerializer.cs | 31 ++++++++++++-- .../ProtobufCustomSubtypeTests.cs | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/Dock.Serializer.UnitTests/ProtobufCustomSubtypeTests.cs diff --git a/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs b/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs index 452698e44..3cc0d84a1 100644 --- a/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs +++ b/src/Dock.Serializer.Protobuf/ProtobufDockSerializer.cs @@ -108,7 +108,10 @@ private static RuntimeTypeModel BuildModel() var interfaceHierarchy = BuildInterfaceHierarchy(dockInterfaces); var classHierarchy = BuildClassHierarchy(dockInterfaces, dockClasses); - foreach (var baseType in dockInterfaces) + var baseTypes = new HashSet(dockInterfaces); + baseTypes.UnionWith(classHierarchy.Keys); + + foreach (var baseType in baseTypes) { var derivedTypes = new List(); if (interfaceHierarchy.TryGetValue(baseType, out var interfaceTypes)) @@ -225,21 +228,41 @@ private static Dictionary> BuildClassHierarchy( IReadOnlyCollection dockInterfaces, IReadOnlyCollection dockClasses) { + var classSet = new HashSet(dockClasses); var map = new Dictionary>(); foreach (var dockClass in dockClasses) { - var baseInterface = GetClosestDockInterface(dockClass, dockInterfaces); - if (baseInterface is null) + // A subclass must be registered under its nearest concrete Dock base class, not just an + // interface, or protobuf-net's base-chain dispatch throws "Unexpected sub-type". + var registrationBase = FindNearestTrackedBaseClass(dockClass, classSet) + ?? GetClosestDockInterface(dockClass, dockInterfaces); + if (registrationBase is null) { continue; } - AddToTypeMap(map, baseInterface, dockClass); + AddToTypeMap(map, registrationBase, dockClass); } return map; } + private static Type? FindNearestTrackedBaseClass(Type type, IReadOnlyCollection classSet) + { + var current = type.BaseType; + while (current is not null && current != typeof(object)) + { + if (classSet.Contains(current)) + { + return current; + } + + current = current.BaseType; + } + + return null; + } + private static Type? GetClosestDockInterface(Type type, IReadOnlyCollection dockInterfaces) { var candidates = type.GetInterfaces() diff --git a/tests/Dock.Serializer.UnitTests/ProtobufCustomSubtypeTests.cs b/tests/Dock.Serializer.UnitTests/ProtobufCustomSubtypeTests.cs new file mode 100644 index 000000000..23b4e1b0b --- /dev/null +++ b/tests/Dock.Serializer.UnitTests/ProtobufCustomSubtypeTests.cs @@ -0,0 +1,42 @@ +using System.Collections.ObjectModel; +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.Inpc.Controls; +using Dock.Serializer.Protobuf; +using Xunit; + +namespace Dock.Serializer.UnitTests; + +// Must be public: protobuf-net type discovery only considers public types. +public sealed class ProtobufCustomDocument : Document +{ +} + +public class ProtobufCustomSubtypeTests +{ + [Fact] + public void SaveLoad_Roundtrip_PreservesCustomDockableSubtype() + { + var document = new ProtobufCustomDocument { Id = "Home", Title = "Home" }; + var documentDock = new DocumentDock + { + Id = "DocumentDock", + VisibleDockables = new ObservableCollection { document }, + ActiveDockable = document, + }; + IRootDock layout = new RootDock + { + Id = "Root", + VisibleDockables = new ObservableCollection { documentDock }, + ActiveDockable = documentDock, + }; + + var serializer = new ProtobufDockSerializer(); + var text = serializer.Serialize(layout); + var restored = serializer.Deserialize(text); + + Assert.NotNull(restored); + var restoredDocDock = Assert.IsType(restored!.VisibleDockables![0]); + Assert.IsType(restoredDocDock.VisibleDockables![0]); + } +} From 1d66aeca03d95a64c7118071597d9fbb0ad60e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 14 Jul 2026 22:12:02 +0200 Subject: [PATCH 5/5] Preserve Dock list constructors for NativeAOT --- .../DockListTypeInfoModifier.cs | 42 +++++++++++++++++++ .../SystemTextJsonDockSerializerTests.cs | 21 ++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs b/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs index 166af119e..085549840 100644 --- a/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs +++ b/src/Dock.Serializer.SystemTextJson/DockListTypeInfoModifier.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Text.Json.Serialization.Metadata; +using Dock.Model.Core; namespace Dock.Serializer.SystemTextJson; @@ -25,8 +27,48 @@ public static void Apply(JsonTypeInfo typeInfo, Type listType) return; } + if (TryAssignAotSafeCreator(typeInfo, type, listType)) + { + return; + } + var elementType = type.GetGenericArguments()[0]; var concreteListType = listType.MakeGenericType(elementType); typeInfo.CreateObject = () => Activator.CreateInstance(concreteListType)!; } + + private static bool TryAssignAotSafeCreator(JsonTypeInfo typeInfo, Type type, Type listType) + { + if (listType == typeof(ObservableCollection<>)) + { + if (type == typeof(IList)) + { + typeInfo.CreateObject = static () => new ObservableCollection(); + return true; + } + + if (type == typeof(IList)) + { + typeInfo.CreateObject = static () => new ObservableCollection(); + return true; + } + } + + if (listType == typeof(List<>)) + { + if (type == typeof(IList)) + { + typeInfo.CreateObject = static () => new List(); + return true; + } + + if (type == typeof(IList)) + { + typeInfo.CreateObject = static () => new List(); + return true; + } + } + + return false; + } } diff --git a/tests/Dock.Serializer.UnitTests/SystemTextJsonDockSerializerTests.cs b/tests/Dock.Serializer.UnitTests/SystemTextJsonDockSerializerTests.cs index 1d2cc9145..d3844bbec 100644 --- a/tests/Dock.Serializer.UnitTests/SystemTextJsonDockSerializerTests.cs +++ b/tests/Dock.Serializer.UnitTests/SystemTextJsonDockSerializerTests.cs @@ -6,6 +6,9 @@ using System.Text; using System.Text.Json.Serialization.Metadata; using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.Inpc.Controls; +using Dock.Model.Inpc.Core; using Dock.Serializer.SystemTextJson; using Xunit; @@ -69,6 +72,24 @@ public void CustomListType_List_DeserializesToList() Assert.IsType>(result!.Numbers); } + [Fact] + public void DockModelLists_UseAotSafeObservableCollections() + { + var serializer = new DockSerializer(); + var root = new RootDock + { + VisibleDockables = new List { new DocumentDock() }, + Windows = new List { new DockWindow() } + }; + + var json = serializer.Serialize(root); + var result = serializer.Deserialize(json); + + Assert.NotNull(result); + Assert.IsType>(result!.VisibleDockables); + Assert.IsType>(result.Windows); + } + [Fact] public void SaveLoad_Roundtrip_Works() {