From c8d2ffc9987bcd73fc2d9e3ac1d87867f90f4fdc Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 03:52:57 +0900
Subject: [PATCH 1/6] fix(editor): bound managed reference child traversal
Why
Unconditional descendant traversal can keep following cyclic references and scans
closed collections while calculating the height of a selector.
What Changed
Respect the requested child depth, retain independent property copies, and scope
owned iterators. Stop a drawing iteration after a child changes its structure.
Cover cyclic Inspector editing, child depth and copy behavior, and array resize.
Impact
Inspector work remains finite for the required children without changing type
eligibility, labels, or collection element enumeration.
---
.../Editor/SerializedPropertyExtensions.cs | 38 ++--
.../Editor/SubclassSelectorDrawer.cs | 38 ++--
.../SerializedPropertyChildTraversalTests.cs | 179 ++++++++++++++++++
...ializedPropertyChildTraversalTests.cs.meta | 11 ++
.../Editor/SubclassSelectorTraversalTests.cs | 119 ++++++++++++
.../SubclassSelectorTraversalTests.cs.meta | 11 ++
.../Editor/TraversalCollectionDrawer.cs | 32 ++++
.../Editor/TraversalCollectionDrawer.cs.meta | 11 ++
.../Editor/TraversalTestAsset.cs | 65 +++++++
.../Editor/TraversalTestAsset.cs.meta | 11 ++
10 files changed, 492 insertions(+), 23 deletions(-)
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs
index 75316bc..154829a 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SerializedPropertyExtensions.cs
@@ -5,25 +5,41 @@ namespace MackySoft.SerializeReferenceExtensions.Editor
{
public static class SerializedPropertyExtensions
{
+ /// Enumerates visible child properties without moving the parent iterator.
+ /// The parent whose iterator position is preserved.
+ /// The maximum descendant depth. Nonpositive values return no children.
+ /// Independent property copies that the caller must dispose.
+ /// Array parents return their elements without entering element descendants, regardless of positive depth.
public static IEnumerable GetChildProperties (this SerializedProperty parent, int depth = 1)
{
- parent = parent.Copy();
-
- int depthOfParent = parent.depth;
- var enumerator = parent.GetEnumerator();
+ if (depth <= 0)
+ {
+ yield break;
+ }
- while (enumerator.MoveNext())
+ using (SerializedProperty cursor = parent.Copy())
{
- if (enumerator.Current is not SerializedProperty childProperty)
+ if (cursor.isArray)
{
- continue;
+ for (int i = 0; i < cursor.arraySize; i++)
+ {
+ yield return cursor.GetArrayElementAtIndex(i);
+ }
+ yield break;
}
- if (childProperty.depth > (depthOfParent + depth))
+
+ int depthOfParent = cursor.depth;
+ long maximumDepth = (long)depthOfParent + depth;
+ using (SerializedProperty end = cursor.GetEndProperty())
{
- continue;
+ // Skip descendants at the requested depth so cycles and closed branches remain bounded.
+ while (cursor.NextVisible(cursor.depth < maximumDepth) &&
+ !SerializedProperty.EqualContents(cursor, end) && cursor.depth > depthOfParent)
+ {
+ yield return cursor.Copy();
+ }
}
- yield return childProperty.Copy();
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
index c91b6be..bf3e590 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
@@ -133,11 +133,22 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte
childPosition.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
foreach (SerializedProperty childProperty in property.GetChildProperties())
{
- float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true);
- childPosition.height = height;
- EditorGUI.PropertyField(childPosition, childProperty, true);
-
- childPosition.y += height + EditorGUIUtility.standardVerticalSpacing;
+ using (childProperty)
+ {
+ float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true);
+ childPosition.height = height;
+ using (var changeCheck = new EditorGUI.ChangeCheckScope())
+ {
+ EditorGUI.PropertyField(childPosition, childProperty, true);
+ // A child can replace its structure and invalidate the active iterator.
+ if (changeCheck.changed)
+ {
+ break;
+ }
+ }
+
+ childPosition.y += height + EditorGUIUtility.standardVerticalSpacing;
+ }
}
}
}
@@ -278,16 +289,19 @@ private static float GetChildrenHeight (SerializedProperty property)
foreach (SerializedProperty child in property.GetChildProperties())
{
- if (!first)
+ using (child)
{
- height += EditorGUIUtility.standardVerticalSpacing;
- }
- first = false;
+ if (!first)
+ {
+ height += EditorGUIUtility.standardVerticalSpacing;
+ }
+ first = false;
- TempChildLabel.text = child.displayName;
- TempChildLabel.tooltip = child.tooltip;
+ TempChildLabel.text = child.displayName;
+ TempChildLabel.tooltip = child.tooltip;
- height += EditorGUI.GetPropertyHeight(child, TempChildLabel, true);
+ height += EditorGUI.GetPropertyHeight(child, TempChildLabel, true);
+ }
}
return height;
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs
new file mode 100644
index 0000000..58a37d4
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs
@@ -0,0 +1,179 @@
+using System.Collections.Generic;
+using System.Linq;
+using MackySoft.SerializeReferenceExtensions.Editor;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using Asset = MackySoft.SerializeReferenceExtensions.Tests.TraversalTestAsset;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class SerializedPropertyChildTraversalTests
+ {
+ private Asset target;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ target = ScriptableObject.CreateInstance();
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ if (target != null)
+ {
+ Object.DestroyImmediate(target);
+ }
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void CyclicReferences_ReturnTheRequestedFiniteDepth (bool mutual)
+ {
+ var first = new Asset.Cycle();
+ var second = mutual ? new Asset.Cycle() : first;
+ first.next = second;
+ second.next = first;
+ target.reference = first;
+
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty("reference"))
+ using (SerializedProperty next = parent.FindPropertyRelative("next"))
+ {
+ parent.isExpanded = true;
+ next.isExpanded = false;
+ Assert.That(Paths(parent, 2), Is.EqualTo(new[]
+ {
+ "reference.visibleValue", "reference.next", "reference.next.visibleValue",
+ "reference.next.next", "reference.next.tailValue", "reference.tailValue"
+ }));
+ }
+ }
+
+ [Test]
+ public void DirectChildren_ClosedCollectionDoesNotEnterCyclicElements ()
+ {
+ var entries = new Asset.Cycle[128];
+ for (int i = 0; i < entries.Length; i++)
+ {
+ var entry = new Asset.Cycle();
+ entry.next = entry;
+ entries[i] = entry;
+ }
+ target.reference = new Asset.ClosedBranches { entries = entries };
+
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty("reference"))
+ using (SerializedProperty collection = parent.FindPropertyRelative("entries"))
+ {
+ parent.isExpanded = true;
+ collection.isExpanded = false;
+ Assert.That(Paths(parent), Is.EqualTo(new[]
+ {
+ "reference.visibleValue", "reference.entries", "reference.tailValue"
+ }));
+ }
+ }
+
+ [Test]
+ public void RequestedDepth_IncludesVisibleChildrenAndStopsBeforeGrandchildren ()
+ {
+ target.reference = new Asset.NestedRoot();
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty("reference"))
+ {
+ Assert.That(Paths(parent, 2), Is.EqualTo(new[]
+ {
+ "reference.visibleValue", "reference.branch", "reference.branch.value",
+ "reference.branch.leaf", "reference.tailValue"
+ }));
+ }
+ }
+
+ [TestCase(0)]
+ [TestCase(-1)]
+ public void NonpositiveDepth_ReturnsNoChildren (int depth)
+ {
+ var cycle = new Asset.Cycle();
+ cycle.next = cycle;
+ target.reference = cycle;
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty("reference"))
+ {
+ Assert.That(Paths(parent, depth), Is.Empty);
+ Assert.That(parent.propertyPath, Is.EqualTo("reference"));
+ }
+ }
+
+ [TestCase("array")]
+ [TestCase("list")]
+ public void ArrayParents_ReturnElementsWithoutTheirDescendants (string path)
+ {
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty(path))
+ {
+ Assert.That(Paths(parent, 3), Is.EqualTo(new[]
+ {
+ path + ".Array.data[0]", path + ".Array.data[1]"
+ }));
+ Assert.That(parent.propertyPath, Is.EqualTo(path));
+ }
+ }
+
+ [Test]
+ public void ReturnedCopies_RemainIndependentAndPreserveParentPosition ()
+ {
+ target.reference = new Asset.NestedRoot();
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty parent = serialized.FindProperty("reference"))
+ {
+ SerializedProperty[] children = parent.GetChildProperties().ToArray();
+ try
+ {
+ Assert.That(children.Select(child => child.propertyPath), Is.EqualTo(new[]
+ {
+ "reference.visibleValue", "reference.branch", "reference.tailValue"
+ }));
+ Assert.That(children[0].NextVisible(false), Is.True);
+ Assert.That(children[1].propertyPath, Is.EqualTo("reference.branch"));
+ Assert.That(children[2].propertyPath, Is.EqualTo("reference.tailValue"));
+ children[0].NextVisible(false);
+ Assert.That(children[1].propertyPath, Is.EqualTo("reference.branch"));
+ Assert.That(parent.propertyPath, Is.EqualTo("reference"));
+ Assert.That(parent.managedReferenceValue, Is.SameAs(target.reference));
+ }
+ finally
+ {
+ foreach (SerializedProperty child in children)
+ {
+ child.Dispose();
+ }
+ }
+
+ using (IEnumerator iterator = parent.GetChildProperties().GetEnumerator())
+ {
+ Assert.That(iterator.MoveNext(), Is.True);
+ using (SerializedProperty first = iterator.Current)
+ {
+ Assert.That(first.propertyPath, Is.EqualTo("reference.visibleValue"));
+ }
+ }
+ Assert.That(parent.propertyPath, Is.EqualTo("reference"));
+ }
+ }
+
+ private static string[] Paths (SerializedProperty parent, int depth = 1)
+ {
+ var paths = new List();
+ foreach (SerializedProperty child in parent.GetChildProperties(depth))
+ {
+ using (child)
+ {
+ paths.Add(child.propertyPath);
+ }
+ }
+ return paths.ToArray();
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta
new file mode 100644
index 0000000..ba71fc4
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 243819863a67e42bc9e99c43b17f7dee
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs
new file mode 100644
index 0000000..ae64551
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs
@@ -0,0 +1,119 @@
+using System.Collections;
+using System.Linq;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.TestTools;
+using Asset = MackySoft.SerializeReferenceExtensions.Tests.TraversalTestAsset;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class SubclassSelectorTraversalTests
+ {
+ private Asset target;
+ private SelectorInspectorWindow window;
+
+ [UnitySetUp]
+ public IEnumerator SetUp ()
+ {
+ target = ScriptableObject.CreateInstance();
+ window = ScriptableObject.CreateInstance();
+ window.Initialize(target, "reference", true);
+ window.ShowUtility();
+ yield return null;
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ if (window != null)
+ {
+ window.Close();
+ }
+ if (target != null)
+ {
+ Object.DestroyImmediate(target);
+ }
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void CyclicReferences_InspectorDrawsAndEditsVisibleChildren (bool mutual)
+ {
+ var first = new Asset.Cycle();
+ var second = mutual ? new Asset.Cycle { visibleValue = 43 } : first;
+ first.next = second;
+ second.next = first;
+ target.reference = first;
+ SetExpanded("reference.next", false);
+
+ InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window);
+ AssertChildrenFit(contents, "Visible Value", "Next", "Tail Value");
+ ReplaceNumber(contents, "17", "42");
+
+ Assert.That(((Asset.Cycle)target.reference).visibleValue, Is.EqualTo(42));
+ Assert.That(((Asset.Cycle)target.reference).tailValue, Is.EqualTo(71));
+ if (mutual)
+ {
+ Assert.That(((Asset.Cycle)target.reference).next.visibleValue, Is.EqualTo(43));
+ }
+ AssertChildrenFit(InspectorDrawing.Capture(window), "Visible Value", "Next", "Tail Value");
+ }
+
+ [Test]
+ public void ChildControlResizesArray_UpdatesValuesAndFollowingLayout ()
+ {
+ target.reference = new Asset.CollectionRoot();
+ InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(window);
+ Rect resize = contents.Single(item => item.Text == TraversalCollectionDrawer.ResizeButton).Rect;
+ window.Focus();
+ Click(resize.center);
+
+ var value = (Asset.CollectionRoot)target.reference;
+ Assert.That(value.collection.values, Has.Length.EqualTo(3));
+ Assert.That(value.collection.values[0], Is.EqualTo(31));
+ Assert.That(value.visibleValue, Is.EqualTo(17));
+ Assert.That(value.tailValue, Is.EqualTo(71));
+ AssertChildrenFit(InspectorDrawing.Capture(window), "Visible Value", "Collection", TraversalCollectionDrawer.ResizeButton, "Tail Value");
+ }
+
+ private void SetExpanded (string path, bool expanded)
+ {
+ using (var serialized = new SerializedObject(target))
+ using (SerializedProperty property = serialized.FindProperty(path))
+ {
+ property.isExpanded = expanded;
+ }
+ }
+
+ private static void AssertChildrenFit (InspectorDrawing.DrawnContent[] contents, params string[] labels)
+ {
+ Rect following = contents.Single(item => item.Text == SelectorInspectorWindow.FollowingLabel).Rect;
+ foreach (string label in labels)
+ {
+ Rect child = contents.Single(item => item.Text == label).Rect;
+ Assert.That(child.yMax, Is.LessThanOrEqualTo(following.yMin), label + " must fit before the following field.");
+ }
+ }
+
+ private void Click (Vector2 position)
+ {
+ window.SendEvent(new Event { type = EventType.MouseDown, mousePosition = position, button = 0 });
+ window.SendEvent(new Event { type = EventType.MouseUp, mousePosition = position, button = 0 });
+ }
+
+ private void ReplaceNumber (InspectorDrawing.DrawnContent[] contents, string current, string replacement)
+ {
+ Rect input = contents.First(item => item.Text == current).Rect;
+ window.Focus();
+ Click(input.center);
+ EventModifiers modifier = Application.platform == RuntimePlatform.OSXEditor ? EventModifiers.Command : EventModifiers.Control;
+ window.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.A, modifiers = modifier });
+ foreach (char character in replacement)
+ {
+ window.SendEvent(new Event { type = EventType.KeyDown, character = character });
+ }
+ window.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Return, character = '\n' });
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta
new file mode 100644
index 0000000..1120a24
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorTraversalTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f52cd87a8986043fcaa4aa98562aefab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs
new file mode 100644
index 0000000..e8fa688
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs
@@ -0,0 +1,32 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ [CustomPropertyDrawer(typeof(TraversalTestAsset.ResizableCollection))]
+ public sealed class TraversalCollectionDrawer : PropertyDrawer
+ {
+ public const string ResizeButton = "Add child items";
+
+ public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
+ {
+ return EditorGUIUtility.singleLineHeight * 2f + EditorGUIUtility.standardVerticalSpacing;
+ }
+
+ public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
+ {
+ var header = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
+ EditorGUI.LabelField(header, label);
+ var button = new Rect(position.x, header.yMax + EditorGUIUtility.standardVerticalSpacing, position.width, EditorGUIUtility.singleLineHeight);
+ if (GUI.Button(button, ResizeButton))
+ {
+ using (SerializedProperty values = property.FindPropertyRelative("values"))
+ {
+ values.arraySize += 2;
+ property.serializedObject.ApplyModifiedProperties();
+ }
+ GUI.changed = true;
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta
new file mode 100644
index 0000000..d7bcf80
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalCollectionDrawer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 66475e37db4894f319060868110a7f87
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs
new file mode 100644
index 0000000..762ef17
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class TraversalTestAsset : ScriptableObject
+ {
+ [SerializeReference, SubclassSelector] public object reference;
+ public Branch[] array = { new Branch(), new Branch() };
+ public List list = new List { new Branch(), new Branch() };
+
+ [Serializable]
+ public sealed class Cycle
+ {
+ public int visibleValue = 17;
+ [SerializeReference, SubclassSelector] public Cycle next;
+ public int tailValue = 71;
+ }
+
+ [Serializable]
+ public sealed class NestedRoot
+ {
+ public int visibleValue = 17;
+ public Branch branch = new Branch();
+ [HideInInspector] public int hiddenValue;
+ public int tailValue = 71;
+ }
+
+ [Serializable]
+ public sealed class Branch
+ {
+ public int value = 23;
+ public Leaf leaf = new Leaf();
+ }
+
+ [Serializable]
+ public sealed class Leaf
+ {
+ public int value = 29;
+ }
+
+ [Serializable]
+ public sealed class CollectionRoot
+ {
+ public int visibleValue = 17;
+ public ResizableCollection collection = new ResizableCollection();
+ public int tailValue = 71;
+ }
+
+ [Serializable]
+ public sealed class ResizableCollection
+ {
+ public int[] values = { 31 };
+ }
+
+ [Serializable]
+ public sealed class ClosedBranches
+ {
+ public int visibleValue = 17;
+ [SerializeReference, SubclassSelector] public Cycle[] entries;
+ public int tailValue = 71;
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta
new file mode 100644
index 0000000..90c078b
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/TraversalTestAsset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 86463595d0a1744cba7e5053c2e17ed8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From eee7032b033958b24b705c1db88d7c8be8be76f9 Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 04:05:57 +0900
Subject: [PATCH 2/6] fix(editor): delegate subtype drawers through Unity
handlers
Why
A type-wide drawer instance shares state across unrelated Inspector fields and
has neither the declaring-field context nor Unity's normal lifetime owner.
What Changed
Use Unity's current property-handler chain to measure and draw subtype content.
Cache only drawer-type metadata for the public type-only lookup, which now
returns an unbound caller-owned instance.
Impact
Contextual drawers receive Unity's field metadata, nesting and disposal behavior.
Public lookup signatures and type-discovery policies remain unchanged.
---
.../Editor/PropertyDrawerCache.cs | 18 +++++----
.../Editor/SubclassSelectorDrawer.cs | 25 +++---------
.../Editor/UnityPropertyDrawerUtility.cs | 40 +++++++++++++++++++
.../Editor/UnityPropertyDrawerUtility.cs.meta | 11 +++++
4 files changed, 68 insertions(+), 26 deletions(-)
create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs
create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs
index 868413f..c918263 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs
@@ -8,17 +8,21 @@ namespace MackySoft.SerializeReferenceExtensions.Editor
public static class PropertyDrawerCache
{
- private static readonly Dictionary Caches = new Dictionary();
+ private static readonly Dictionary DrawerTypes = new Dictionary();
+ /// Creates an unbound custom drawer registered for the specified type.
+ /// The value type whose drawer registration is resolved.
+ /// A fresh drawer when a registration exists; otherwise, null. The caller owns this instance and must release it if it implements .
+ /// True when a custom drawer registration exists.
+ /// A type-only lookup supplies neither declaring-field context nor Unity handler ownership.
public static bool TryGetPropertyDrawer (Type type, out PropertyDrawer drawer)
{
- if (!Caches.TryGetValue(type, out drawer))
+ if (!DrawerTypes.TryGetValue(type, out Type drawerType))
{
- Type drawerType = GetCustomPropertyDrawerType(type);
- drawer = (drawerType != null) ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null;
-
- Caches.Add(type, drawer);
+ drawerType = GetCustomPropertyDrawerType(type);
+ DrawerTypes.Add(type, drawerType);
}
+ drawer = (drawerType != null) ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null;
return (drawer != null);
}
@@ -77,4 +81,4 @@ private static Type GetCustomPropertyDrawerType (Type type)
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
index bf3e590..879fda8 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
@@ -112,16 +112,14 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte
{
using (new EditorGUI.IndentLevelScope())
{
- // Check if a custom property drawer exists for this type.
- PropertyDrawer customDrawer = GetCustomPropertyDrawer(property);
- if (customDrawer != null)
+ if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property))
{
- // Draw the property with custom property drawer.
+ // Let Unity invoke the next drawer with its declaring-field context and native ownership.
Rect indentedRect = position;
float foldoutDifference = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
- indentedRect.height = customDrawer.GetPropertyHeight(property, label);
+ indentedRect.height = EditorGUI.GetPropertyHeight(property, label, true);
indentedRect.y += foldoutDifference;
- customDrawer.OnGUI(indentedRect, property, label);
+ EditorGUI.PropertyField(indentedRect, property, label, true);
}
else
{
@@ -168,16 +166,6 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte
EditorGUI.EndProperty();
}
- private PropertyDrawer GetCustomPropertyDrawer (SerializedProperty property)
- {
- Type propertyType = ManagedReferenceUtility.GetType(property.managedReferenceFullTypename);
- if (propertyType != null && PropertyDrawerCache.TryGetPropertyDrawer(propertyType, out PropertyDrawer drawer))
- {
- return drawer;
- }
- return null;
- }
-
private TypePopupCache GetTypePopup (SerializedProperty property)
{
// Cache this string. This property internally call Assembly.GetName, which result in a large allocation.
@@ -270,10 +258,9 @@ public override float GetPropertyHeight (SerializedProperty property, GUIContent
float height = EditorGUIUtility.singleLineHeight;
height += EditorGUIUtility.standardVerticalSpacing;
- PropertyDrawer customDrawer = GetCustomPropertyDrawer(property);
- if (customDrawer != null)
+ if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property))
{
- height += customDrawer.GetPropertyHeight(property, label);
+ height += EditorGUI.GetPropertyHeight(property, label, true);
return height;
}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs
new file mode 100644
index 0000000..ae5bc22
--- /dev/null
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Linq.Expressions;
+using System.Reflection;
+using UnityEditor;
+
+namespace MackySoft.SerializeReferenceExtensions.Editor
+{
+ internal static class UnityPropertyDrawerUtility
+ {
+ private static readonly Func HasNextDrawer = BindHasNextDrawer();
+
+ internal static bool HasNextPropertyDrawer (SerializedProperty property)
+ {
+ return HasNextDrawer(property);
+ }
+
+ private static Func BindHasNextDrawer ()
+ {
+ Assembly editorAssembly = typeof(EditorGUI).Assembly;
+ Type utilityType = editorAssembly.GetType("UnityEditor.ScriptAttributeUtility", true);
+ Type handlerType = editorAssembly.GetType("UnityEditor.PropertyHandler", true);
+ MethodInfo getHandler = utilityType.GetMethod("GetHandler", BindingFlags.Static | BindingFlags.NonPublic,
+ null, new[] { typeof(SerializedProperty) }, null);
+ PropertyInfo hasDrawer = handlerType.GetProperty("hasPropertyDrawer", BindingFlags.Instance | BindingFlags.Public);
+ if (getHandler == null)
+ {
+ throw new MissingMethodException(utilityType.FullName, "GetHandler");
+ }
+ if (hasDrawer == null)
+ {
+ throw new MissingMemberException(handlerType.FullName, "hasPropertyDrawer");
+ }
+
+ // Unity's current nesting level determines the next drawer and keeps its context and lifetime native-owned.
+ var property = Expression.Parameter(typeof(SerializedProperty), "property");
+ return Expression.Lambda>(
+ Expression.Property(Expression.Call(getHandler, property), hasDrawer), property).Compile();
+ }
+ }
+}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta
new file mode 100644
index 0000000..e3f8f66
--- /dev/null
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ab2a86b3864b34abf988416770b698bb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From 3944723047c539010000a64d8b853dd37f065e3a Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 04:15:07 +0900
Subject: [PATCH 3/6] test(editor): cover native subtype drawer context and
lifetime
Why
Subtype drawers must observe the declaring field and retain state only within
Unity's normal Inspector ownership boundaries.
What Changed
Exercise registered consumer drawers through native Inspector editors across
hosts and independent fields, including edits, forwarding and owner disposal.
Check the public type-only helper's fresh unbound caller-owned results.
Impact
Detects the previous missing field context and protects native drawer behavior
on both supported Unity baselines without asserting private cache structure.
---
.../Editor/ContextualNodeDrawer.cs | 68 +++++++
.../Editor/ContextualNodeDrawer.cs.meta | 11 ++
.../Editor/DrawerContextAttribute.cs | 19 ++
.../Editor/DrawerContextAttribute.cs.meta | 11 ++
.../Editor/DrawerContextTestAsset.cs | 32 ++++
.../Editor/DrawerContextTestAsset.cs.meta | 11 ++
.../Editor/DrawerContextTestEditor.cs | 28 +++
.../Editor/DrawerContextTestEditor.cs.meta | 11 ++
.../Editor/ForwardingNodeDrawer.cs | 19 ++
.../Editor/ForwardingNodeDrawer.cs.meta | 11 ++
.../SubclassSelectorDrawerContextTests.cs | 174 ++++++++++++++++++
...SubclassSelectorDrawerContextTests.cs.meta | 11 ++
12 files changed, 406 insertions(+)
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs
new file mode 100644
index 0000000..e9c1cbe
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEditor;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ [CustomPropertyDrawer(typeof(DrawerContextTestAsset.StatefulNode))]
+ public sealed class ContextualNodeDrawer : PropertyDrawer, IDisposable
+ {
+ public static readonly List<(int TargetId, string PropertyPath)> DisposedContexts = new List<(int, string)>();
+ private int observedTargetId;
+ private string observedPath;
+ private int edits;
+
+ public bool IsDisposed { get; private set; }
+
+ public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
+ {
+ DrawerContextAttribute context = fieldInfo?.GetCustomAttribute();
+ int lines = context?.Lines ?? 1;
+ return EditorGUIUtility.singleLineHeight * lines + EditorGUIUtility.standardVerticalSpacing * (lines - 1);
+ }
+
+ public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
+ {
+ if (observedPath == null)
+ {
+ observedTargetId = property.serializedObject.targetObject.GetInstanceID();
+ observedPath = property.propertyPath;
+ }
+
+ DrawerContextAttribute context = fieldInfo?.GetCustomAttribute();
+ var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
+ if (context == null)
+ {
+ EditorGUI.LabelField(line, "Missing declaring-field context");
+ return;
+ }
+
+ EditorGUI.LabelField(line, context.Caption);
+ line.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
+ EditorGUI.LabelField(line, context.Caption + " edits: " + edits);
+ line.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
+ EditorGUI.LabelField(line, "Tooltip: " + label.tooltip);
+ var button = new Rect(position.x, position.yMax - EditorGUIUtility.singleLineHeight, position.width, EditorGUIUtility.singleLineHeight);
+ if (GUI.Button(button, "Edit " + context.Caption))
+ {
+ using (SerializedProperty amount = property.FindPropertyRelative("amount"))
+ {
+ amount.intValue += context.Increment;
+ }
+ edits++;
+ GUI.changed = true;
+ }
+ }
+
+ public void Dispose ()
+ {
+ IsDisposed = true;
+ if (observedPath != null)
+ {
+ DisposedContexts.Add((observedTargetId, observedPath));
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta
new file mode 100644
index 0000000..7ed5e6c
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 194f4f53e75ee4e87a924f1e30f653ff
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs
new file mode 100644
index 0000000..27c7161
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ [AttributeUsage(AttributeTargets.Field)]
+ public sealed class DrawerContextAttribute : Attribute
+ {
+ public string Caption { get; }
+ public int Increment { get; }
+ public int Lines { get; }
+
+ public DrawerContextAttribute (string caption, int increment, int lines)
+ {
+ Caption = caption;
+ Increment = increment;
+ Lines = lines;
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta
new file mode 100644
index 0000000..9a9813c
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextAttribute.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cea9ab057c9e04390b28d5fdddc8d0c2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs
new file mode 100644
index 0000000..d424c32
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs
@@ -0,0 +1,32 @@
+using System;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DrawerContextTestAsset : ScriptableObject
+ {
+ [SerializeReference, SubclassSelector, DrawerContext("Primary selector", 7, 4)]
+ public StatefulNode primary = new StatefulNode { amount = 101 };
+
+ [SerializeReference, SubclassSelector, DrawerContext("Secondary selector", 13, 5)]
+ public StatefulNode secondary = new StatefulNode { amount = 201 };
+
+ [SerializeReference, DrawerContext("Native reference", 17, 4)]
+ public StatefulNode native = new StatefulNode { amount = 301 };
+
+ [SerializeReference, SubclassSelector]
+ public ForwardingNode forwarding = new ForwardingNode();
+
+ [Serializable]
+ public sealed class StatefulNode
+ {
+ public int amount;
+ }
+
+ [Serializable]
+ public sealed class ForwardingNode
+ {
+ public int number = 29;
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta
new file mode 100644
index 0000000..f1c41ab
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestAsset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 93a7f6c5ef35f48dfa629b10298d4188
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs
new file mode 100644
index 0000000..b107f20
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs
@@ -0,0 +1,28 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ [CustomEditor(typeof(DrawerContextTestAsset))]
+ public sealed class DrawerContextTestEditor : UnityEditor.Editor
+ {
+ public const string FollowingLabel = "Following context field";
+ public const string Tooltip = "Consumer context tooltip";
+ public string PropertyPath { get; set; } = "primary";
+
+ public override void OnInspectorGUI ()
+ {
+ serializedObject.Update();
+ using (SerializedProperty property = serializedObject.FindProperty(PropertyPath))
+ {
+ property.isExpanded = true;
+ var label = new GUIContent(property.displayName, Tooltip);
+ float height = EditorGUI.GetPropertyHeight(property, label, true);
+ Rect position = GUILayoutUtility.GetRect(0f, height, GUILayout.ExpandWidth(true));
+ EditorGUI.PropertyField(position, property, label, true);
+ }
+ EditorGUILayout.LabelField(FollowingLabel);
+ serializedObject.ApplyModifiedProperties();
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta
new file mode 100644
index 0000000..03a4040
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DrawerContextTestEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 31b75f1f748e74ea88c4fada3b435b5f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs
new file mode 100644
index 0000000..20bcb3e
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs
@@ -0,0 +1,19 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ [CustomPropertyDrawer(typeof(DrawerContextTestAsset.ForwardingNode))]
+ public sealed class ForwardingNodeDrawer : PropertyDrawer
+ {
+ public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
+ {
+ return EditorGUI.GetPropertyHeight(property, label, true);
+ }
+
+ public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
+ {
+ EditorGUI.PropertyField(position, property, label, true);
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta
new file mode 100644
index 0000000..4a971ea
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ForwardingNodeDrawer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 71a7d704512ff447c972ec53e71b821c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs
new file mode 100644
index 0000000..ba15cf3
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections;
+using System.Linq;
+using MackySoft.SerializeReferenceExtensions.Editor;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.TestTools;
+using Asset = MackySoft.SerializeReferenceExtensions.Tests.DrawerContextTestAsset;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class SubclassSelectorDrawerContextTests
+ {
+ private Asset[] targets;
+ private DrawerContextTestEditor[] editors;
+ private SelectorInspectorWindow[] windows;
+
+ [UnitySetUp]
+ public IEnumerator SetUp ()
+ {
+ ContextualNodeDrawer.DisposedContexts.Clear();
+ targets = new Asset[2];
+ editors = new DrawerContextTestEditor[2];
+ windows = new SelectorInspectorWindow[2];
+ for (int i = 0; i < targets.Length; i++)
+ {
+ targets[i] = ScriptableObject.CreateInstance();
+ targets[i].name = "Drawer context host " + i;
+ targets[i].primary.amount += i * 200;
+ targets[i].secondary.amount += i * 200;
+ editors[i] = (DrawerContextTestEditor)UnityEditor.Editor.CreateEditor(targets[i]);
+ windows[i] = ScriptableObject.CreateInstance();
+ windows[i].position = new Rect(100f + i * 20f, 100f + i * 20f, 600f, 500f);
+ // InspectorElement supplies Unity's normal per-Editor handler context and ownership.
+ windows[i].rootVisualElement.Add(new InspectorElement(editors[i]));
+ windows[i].ShowUtility();
+ }
+ yield return null;
+ yield return null;
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ for (int i = 0; i < targets.Length; i++)
+ {
+ ReleaseEditor(i);
+ if (windows[i] != null)
+ {
+ windows[i].Close();
+ }
+ if (targets[i] != null)
+ {
+ UnityEngine.Object.DestroyImmediate(targets[i]);
+ }
+ }
+ }
+
+ [Test]
+ public void RegisteredCustomDrawer_UsesFieldMetadataAndIndependentHostFieldState ()
+ {
+ AssertContext(0, "primary", "Primary selector", 0);
+ Click(0, "Edit Primary selector");
+ Assert.That(targets[0].primary.amount, Is.EqualTo(108));
+ Assert.That(targets[0].secondary.amount, Is.EqualTo(201));
+
+ AssertContext(0, "secondary", "Secondary selector", 0);
+ Click(0, "Edit Secondary selector");
+ Assert.That(targets[0].secondary.amount, Is.EqualTo(214));
+ Assert.That(targets[0].primary.amount, Is.EqualTo(108));
+
+ AssertContext(1, "primary", "Primary selector", 0);
+ Click(1, "Edit Primary selector");
+ Assert.That(targets[1].primary.amount, Is.EqualTo(308));
+ Assert.That(targets[1].secondary.amount, Is.EqualTo(401));
+ AssertContext(0, "primary", "Primary selector", 1);
+ AssertContext(0, "secondary", "Secondary selector", 1);
+ AssertContext(1, "primary", "Primary selector", 1);
+ }
+
+ [Test]
+ public void NativeEditorDisposal_ReleasesSelectorAndOrdinaryCustomDrawerResources ()
+ {
+ AssertContext(0, "native", "Native reference", 0);
+ AssertContext(0, "primary", "Primary selector", 0);
+ int hostId = targets[0].GetInstanceID();
+ Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.False);
+ Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.False);
+
+ ReleaseEditor(0);
+
+ Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.True);
+ Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.True);
+ }
+
+ [Test]
+ public void CustomDrawer_ForwardingTheSamePropertyReachesDefaultRendering ()
+ {
+ editors[0].PropertyPath = "forwarding";
+ InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(windows[0]);
+ Assert.That(contents.Select(item => item.Text), Does.Contain("Number"));
+ Assert.That(contents.Select(item => item.Text), Does.Contain("29"));
+ AssertFits(contents, "Number");
+ }
+
+ [Test]
+ public void TypeOnlyLookup_ReturnsIndependentUnboundCallerOwnedDrawers ()
+ {
+ PropertyDrawer first = null;
+ PropertyDrawer second = null;
+ try
+ {
+ Assert.That(PropertyDrawerCache.TryGetPropertyDrawer(typeof(Asset.StatefulNode), out first), Is.True);
+ Assert.That(PropertyDrawerCache.TryGetPropertyDrawer(typeof(Asset.StatefulNode), out second), Is.True);
+ Assert.That(first, Is.TypeOf());
+ Assert.That(second, Is.TypeOf());
+ Assert.That(second, Is.Not.SameAs(first));
+ Assert.That(first.fieldInfo, Is.Null);
+ Assert.That(second.fieldInfo, Is.Null);
+
+ ((IDisposable)first).Dispose();
+ Assert.That(((ContextualNodeDrawer)second).IsDisposed, Is.False);
+ }
+ finally
+ {
+ (first as IDisposable)?.Dispose();
+ (second as IDisposable)?.Dispose();
+ }
+ }
+
+ private void AssertContext (int owner, string path, string caption, int edits)
+ {
+ editors[owner].PropertyPath = path;
+ InspectorDrawing.DrawnContent[] contents = InspectorDrawing.Capture(windows[owner]);
+ string[] labels = contents.Select(item => item.Text).ToArray();
+ Assert.That(labels, Does.Contain(caption));
+ Assert.That(labels, Does.Contain(caption + " edits: " + edits));
+ Assert.That(labels, Does.Contain("Tooltip: " + DrawerContextTestEditor.Tooltip));
+ AssertFits(contents, caption, caption + " edits: " + edits, "Edit " + caption);
+ }
+
+ private static void AssertFits (InspectorDrawing.DrawnContent[] contents, params string[] labels)
+ {
+ Rect following = contents.Single(item => item.Text == DrawerContextTestEditor.FollowingLabel).Rect;
+ foreach (string label in labels)
+ {
+ Assert.That(contents.Single(item => item.Text == label).Rect.yMax, Is.LessThanOrEqualTo(following.yMin));
+ }
+ }
+
+ private void Click (int owner, string text)
+ {
+ Rect button = InspectorDrawing.Capture(windows[owner]).Single(item => item.Text == text).Rect;
+ windows[owner].Focus();
+ windows[owner].SendEvent(new Event { type = EventType.MouseDown, mousePosition = button.center, button = 0 });
+ windows[owner].SendEvent(new Event { type = EventType.MouseUp, mousePosition = button.center, button = 0 });
+ }
+
+ private void ReleaseEditor (int owner)
+ {
+ if (windows[owner] != null)
+ {
+ windows[owner].rootVisualElement.Clear();
+ }
+ if (editors[owner] != null)
+ {
+ UnityEngine.Object.DestroyImmediate(editors[owner]);
+ editors[owner] = null;
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta
new file mode 100644
index 0000000..ccbaa40
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 485d5c2ee65ba4536bdb66023b74841a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
From c2dd6525e695050198857bbea3cf1d65a9435e41 Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 04:21:12 +0900
Subject: [PATCH 4/6] fix(editor): validate deferred managed reference edits
Why
Menu callbacks retain expired property iterators and can lose buffered edits or
operate on another collection element after the serialized structure changes.
What Changed
Capture one-shot host and path correspondence without retaining property
iterators, reconcile pending edits, and validate every host before a command.
Use scoped fresh properties and each host's current type and values for edits.
Keep popup selection callbacks in a short-lived dropdown session.
Impact
Valid menu actions preserve edits and Undo; stale targets safely cancel.
Cached popup metadata does not retain an operation's target snapshots.
Existing clipboard scopes and JSON field-preservation semantics remain intact.
---
.../Editor/AdvancedTypePopup.cs | 41 ++
.../Editor/DeferredManagedReferenceTarget.cs | 414 ++++++++++++++++++
.../DeferredManagedReferenceTarget.cs.meta | 11 +
.../ManagedReferenceContextualPropertyMenu.cs | 250 ++++++-----
.../Editor/ManagedReferenceUtility.cs | 60 ++-
.../Editor/SubclassSelectorDrawer.cs | 211 ++++-----
6 files changed, 763 insertions(+), 224 deletions(-)
create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs
index 0c53750..c766a1d 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs
@@ -61,6 +61,39 @@ public TypeMenu (IEnumerable types)
}
+ private sealed class SelectionSession : AdvancedDropdown
+ {
+ private readonly AdvancedTypePopup owner;
+ private Action callback;
+
+ internal SelectionSession (AdvancedTypePopup owner, AdvancedDropdownState state, Action callback) : base(state)
+ {
+ this.owner = owner;
+ this.callback = callback;
+ minimumSize = owner.minimumSize;
+ }
+
+ protected override AdvancedDropdownItem BuildRoot ()
+ {
+ return owner.BuildRoot();
+ }
+
+ protected override void ItemSelected (AdvancedDropdownItem item)
+ {
+ try
+ {
+ if (item is AdvancedTypePopupItem typeItem)
+ {
+ callback?.Invoke(typeItem);
+ }
+ }
+ finally
+ {
+ callback = null;
+ }
+ }
+ }
+
private static bool HasSingleNamespace (TypeMenuItem[] items)
{
// Flatten an unbranched namespace unless an explicit menu requests a category.
@@ -171,11 +204,13 @@ private static AdvancedDropdownItem GetItem (AdvancedDropdownItem parent, string
private Type[] types;
private TypeMenu menu;
+ private readonly AdvancedDropdownState state;
public event Action OnItemSelected;
public AdvancedTypePopup (IEnumerable types, int maxLineCount, AdvancedDropdownState state) : base(state)
{
+ this.state = state;
SetTypes(types);
minimumSize = new Vector2(minimumSize.x, EditorGUIUtility.singleLineHeight * maxLineCount + HeaderHeight);
}
@@ -186,6 +221,12 @@ public void SetTypes (IEnumerable types)
menu = null;
}
+ internal void ShowForSelection (Rect position, Action callback)
+ {
+ // Only the native window owns this session. The cached metadata owner retains no target callback.
+ new SelectionSession(this, state, callback).Show(position);
+ }
+
protected override AdvancedDropdownItem BuildRoot ()
{
var root = new AdvancedDropdownItem("Select Type");
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
new file mode 100644
index 0000000..8c4965c
--- /dev/null
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using UnityEditor;
+
+namespace MackySoft.SerializeReferenceExtensions.Editor
+{
+ internal sealed class DeferredManagedReferenceTarget
+ {
+ private sealed class HostObservation
+ {
+ internal UnityEngine.Object Host;
+ internal long ReferenceId;
+ internal string TypeName;
+ internal string FieldTypeName;
+ internal RuntimePath Path;
+
+ internal bool MatchesProperty (SerializedProperty property)
+ {
+ return property != null && property.propertyType == SerializedPropertyType.ManagedReference &&
+ property.managedReferenceId == ReferenceId && property.managedReferenceFullTypename == TypeName &&
+ property.managedReferenceFieldTypename == FieldTypeName;
+ }
+ }
+
+ private sealed class PathStep
+ {
+ internal FieldInfo Field;
+ internal int Index;
+
+ internal object Read (object container)
+ {
+ if (container == null)
+ {
+ return null;
+ }
+ return Field != null ? Field.GetValue(container) : ((IList)container)[Index];
+ }
+ }
+
+ private sealed class ContainerObservation
+ {
+ private readonly object value;
+ private readonly object[] elements;
+
+ internal ContainerObservation (object value)
+ {
+ this.value = SnapshotValue(value);
+ if (value is IList list)
+ {
+ elements = new object[list.Count];
+ for (int i = 0; i < elements.Length; i++)
+ {
+ elements[i] = SnapshotValue(list[i]);
+ }
+ }
+ }
+
+ internal bool Matches (object current)
+ {
+ if (!SameValue(value, current))
+ {
+ return false;
+ }
+ if (elements != null)
+ {
+ var list = (IList)current;
+ if (list.Count != elements.Length)
+ {
+ return false;
+ }
+ for (int i = 0; i < elements.Length; i++)
+ {
+ if (!SameValue(elements[i], list[i]))
+ {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ }
+
+ private sealed class RuntimePath
+ {
+ private readonly PathStep[] steps;
+ private readonly ContainerObservation[] containers;
+ private readonly object value;
+
+ private RuntimePath (PathStep[] steps, ContainerObservation[] containers, object value)
+ {
+ this.steps = steps;
+ this.containers = containers;
+ this.value = SnapshotValue(value);
+ }
+
+ internal static RuntimePath Capture (UnityEngine.Object host, string propertyPath)
+ {
+ string[] parts = propertyPath.Split('.');
+ var steps = new List();
+ var containers = new List();
+ object current = host;
+ Type currentType = host.GetType();
+ for (int i = 0; i < parts.Length; i++)
+ {
+ PathStep step;
+ if (parts[i] == "Array" && i + 1 < parts.Length && parts[i + 1].StartsWith("data[", StringComparison.Ordinal))
+ {
+ string element = parts[++i];
+ if (!element.EndsWith("]", StringComparison.Ordinal) ||
+ !int.TryParse(element.Substring(5, element.Length - 6), out int index) || index < 0)
+ {
+ return null;
+ }
+ Type elementType = GetElementType(currentType);
+ if (elementType == null || (current != null && (current is not IList list || index >= list.Count)))
+ {
+ return null;
+ }
+ step = new PathStep { Index = index };
+ currentType = elementType;
+ }
+ else
+ {
+ FieldInfo field = FindField(currentType, parts[i]);
+ if (field == null)
+ {
+ return null;
+ }
+ step = new PathStep { Field = field };
+ currentType = field.FieldType;
+ }
+
+ containers.Add(new ContainerObservation(current));
+ steps.Add(step);
+ current = step.Read(current);
+ if (current != null)
+ {
+ currentType = current.GetType();
+ }
+ }
+ return new RuntimePath(steps.ToArray(), containers.ToArray(), current);
+ }
+
+ internal bool Matches (UnityEngine.Object host)
+ {
+ object current = host;
+ for (int i = 0; i < steps.Length; i++)
+ {
+ if (!containers[i].Matches(current))
+ {
+ return false;
+ }
+ current = steps[i].Read(current);
+ }
+ return SameValue(value, current);
+ }
+
+ private static FieldInfo FindField (Type type, string name)
+ {
+ for (; type != null; type = type.BaseType)
+ {
+ FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
+ if (field != null)
+ {
+ return field;
+ }
+ }
+ return null;
+ }
+
+ private static Type GetElementType (Type type)
+ {
+ if (type.IsArray)
+ {
+ return type.GetElementType();
+ }
+ for (; type != null; type = type.BaseType)
+ {
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))
+ {
+ return type.GetGenericArguments()[0];
+ }
+ }
+ return null;
+ }
+ }
+
+ private static readonly Func NativePointer = BindNativePointer();
+
+ private SerializedObject origin;
+ private string propertyPath;
+ private HostObservation[] hosts;
+ private bool consumed;
+
+ private DeferredManagedReferenceTarget (SerializedObject origin, string propertyPath, HostObservation[] hosts)
+ {
+ this.origin = origin;
+ this.propertyPath = propertyPath;
+ this.hosts = hosts;
+ }
+
+ /// Applies the caller's pending edits and captures a one-shot managed-reference menu target.
+ /// The caller retains ownership of its property and SerializedObject. The target retains no temporary native wrappers.
+ internal static DeferredManagedReferenceTarget Capture (SerializedProperty property)
+ {
+ SerializedObject origin = property.serializedObject;
+ string path = property.propertyPath;
+ if (!IsAlive(origin))
+ {
+ return null;
+ }
+ UnityEngine.Object[] targets = origin.targetObjects;
+ if (targets.Length == 0)
+ {
+ return null;
+ }
+ foreach (UnityEngine.Object target in targets)
+ {
+ if (target == null)
+ {
+ return null;
+ }
+ }
+
+ // Commit the caller's buffered edits before observing live instances. The incoming iterator is no longer used.
+ origin.ApplyModifiedProperties();
+ var observations = new HostObservation[targets.Length];
+ for (int i = 0; i < targets.Length; i++)
+ {
+ using (var serialized = new SerializedObject(targets[i]))
+ using (SerializedProperty fresh = serialized.FindProperty(path))
+ {
+ if (fresh == null || fresh.propertyType != SerializedPropertyType.ManagedReference)
+ {
+ return null;
+ }
+ RuntimePath runtimePath = RuntimePath.Capture(targets[i], path);
+ if (runtimePath == null)
+ {
+ return null;
+ }
+ observations[i] = new HostObservation
+ {
+ Host = targets[i],
+ ReferenceId = fresh.managedReferenceId,
+ TypeName = fresh.managedReferenceFullTypename,
+ FieldTypeName = fresh.managedReferenceFieldTypename,
+ Path = runtimePath
+ };
+ }
+ }
+ return new DeferredManagedReferenceTarget(origin, path, observations);
+ }
+
+ /// Reads current corresponding properties to determine menu availability.
+ /// Receives properties borrowed only for this callback; this target releases their wrappers.
+ internal bool TryInspect (Action inspect)
+ {
+ if (consumed || !HasCorrespondence())
+ {
+ return false;
+ }
+ return WithProperties(properties =>
+ {
+ inspect(properties);
+ return true;
+ });
+ }
+
+ /// Runs one command only after the caller and every host still identify the captured target.
+ /// Receives scoped properties and returns true after executing the command, or false when it is unavailable.
+ /// Consumes and releases the captured observations on success, cancellation, or exception.
+ internal bool TryExecute (Func execute)
+ {
+ if (consumed)
+ {
+ return false;
+ }
+ consumed = true;
+ try
+ {
+ // Check runtime identity first: a stale caller buffer must not restore a removed or shifted target.
+ if (!HasCorrespondence())
+ {
+ return false;
+ }
+ origin.ApplyModifiedProperties();
+ bool executed = WithProperties(execute);
+ if (executed)
+ {
+ origin.Update();
+ }
+ return executed;
+ }
+ finally
+ {
+ origin = null;
+ propertyPath = null;
+ hosts = null;
+ }
+ }
+
+ private bool HasCorrespondence ()
+ {
+ if (!IsAlive(origin))
+ {
+ return false;
+ }
+ foreach (HostObservation host in hosts)
+ {
+ if (host.Host == null || !host.Path.Matches(host.Host))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private bool WithProperties (Func execute)
+ {
+ var objects = new SerializedObject[hosts.Length];
+ var properties = new SerializedProperty[hosts.Length];
+ try
+ {
+ for (int i = 0; i < hosts.Length; i++)
+ {
+ if (hosts[i].Host == null)
+ {
+ return false;
+ }
+ objects[i] = new SerializedObject(hosts[i].Host);
+ properties[i] = objects[i].FindProperty(propertyPath);
+ }
+ // Finish opening every native view before the final all-host correspondence check.
+ if (!HasCorrespondence())
+ {
+ return false;
+ }
+ for (int i = 0; i < hosts.Length; i++)
+ {
+ if (!hosts[i].MatchesProperty(properties[i]))
+ {
+ return false;
+ }
+ }
+ return execute(properties);
+ }
+ finally
+ {
+ for (int i = properties.Length - 1; i >= 0; i--)
+ {
+ properties[i]?.Dispose();
+ objects[i]?.Dispose();
+ }
+ }
+ }
+
+ private static object SnapshotValue (object value)
+ {
+ // An interface/object can hold a mutable boxed struct; retain its value rather than that mutable box.
+ return value != null && value.GetType().IsValueType ? RuntimeHelpers.GetObjectValue(value) : value;
+ }
+
+ private static bool SameValue (object expected, object current)
+ {
+ if (ReferenceEquals(expected, current))
+ {
+ return true;
+ }
+ if (expected == null || current == null || expected.GetType() != current.GetType())
+ {
+ return false;
+ }
+ Type type = expected.GetType();
+ if (!type.IsValueType)
+ {
+ return false;
+ }
+ if (type.IsPrimitive || type.IsEnum)
+ {
+ return expected.Equals(current);
+ }
+ // Follow value-type fields only. Reference fields retain identity without visiting their graphs or user Equals.
+ foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
+ {
+ if (!SameValue(field.GetValue(expected), field.GetValue(current)))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static bool IsAlive (SerializedObject serialized)
+ {
+ return serialized != null && NativePointer(serialized) != IntPtr.Zero;
+ }
+
+ private static Func BindNativePointer ()
+ {
+ FieldInfo field = typeof(SerializedObject).GetField("m_NativeObjectPtr", BindingFlags.Instance | BindingFlags.NonPublic);
+ if (field == null)
+ {
+ throw new MissingFieldException(typeof(SerializedObject).FullName, "m_NativeObjectPtr");
+ }
+ var serialized = Expression.Parameter(typeof(SerializedObject), "serialized");
+ return Expression.Lambda>(Expression.Field(serialized, field), serialized).Compile();
+ }
+ }
+}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta
new file mode 100644
index 0000000..05eb800
--- /dev/null
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b70c5f0bc655f4effa5e0e7c57325df7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs
index 3d444ea..ca8b811 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceContextualPropertyMenu.cs
@@ -32,111 +32,148 @@ private static void Initialize ()
private static void OnContextualPropertyMenu (GenericMenu menu, SerializedProperty property)
{
- if (property.propertyType == SerializedPropertyType.ManagedReference)
+ if (property.propertyType != SerializedPropertyType.ManagedReference)
{
- // NOTE: When the callback function is called, the SerializedProperty is rewritten to the property that was being moused over at the time,
- // so a new SerializedProperty instance must be created.
- SerializedProperty clonedProperty = property.Copy();
- // Resolve clipboard targets by path because a property iterator can become invalid while the menu is open.
- object clipboardTarget = (property.serializedObject, property.propertyPath);
-
- var copyContent = new GUIContent($"Copy \"{property.propertyPath}\" property");
- if (CanCopy(clonedProperty))
- {
- menu.AddItem(copyContent, false, Copy, clipboardTarget);
- }
- else
- {
- menu.AddDisabledItem(copyContent);
- }
-
- ClipboardSnapshot snapshot = ReadClipboard();
- if (CanPasteValuesOnly(clonedProperty, snapshot))
- {
- menu.AddItem(new GUIContent(PasteValuesOnlyContent.text, $"Copy matching serialized fields from \"{snapshot.sourcePropertyPath}\"."), false, PasteValuesOnly, clipboardTarget);
- }
- else
- {
- menu.AddDisabledItem(PasteValuesOnlyContent);
- }
-
- if (CanPasteTypeAndValues(clonedProperty, snapshot, out _))
+ return;
+ }
+ string path = property.propertyPath;
+ DeferredManagedReferenceTarget target = DeferredManagedReferenceTarget.Capture(property);
+ ClipboardSnapshot snapshot = ReadClipboard();
+ bool canCopy = false;
+ bool canPasteValues = false;
+ bool canPasteType = false;
+ bool hasInstances = false;
+ target?.TryInspect(properties =>
+ {
+ if (properties.Length == 1)
{
- menu.AddItem(new GUIContent(PasteTypeAndValuesContent.text, $"Replace this reference with a copy of \"{snapshot.sourcePropertyPath}\"."), false, PasteTypeAndValues, clipboardTarget);
+ canCopy = CanCopy(properties[0]);
+ canPasteValues = CanPasteValuesOnly(properties[0], snapshot);
+ canPasteType = CanPasteTypeAndValues(properties[0], snapshot, out _);
}
- else
+ hasInstances = true;
+ foreach (SerializedProperty current in properties)
{
- menu.AddDisabledItem(PasteTypeAndValuesContent);
+ if (!current.editable || current.managedReferenceValue == null)
+ {
+ hasInstances = false;
+ break;
+ }
}
+ });
- menu.AddSeparator("");
+ var copyContent = new GUIContent($"Copy \"{path}\" property");
+ if (canCopy)
+ {
+ menu.AddItem(copyContent, false, Copy, target);
+ }
+ else
+ {
+ menu.AddDisabledItem(copyContent);
+ }
+ if (canPasteValues)
+ {
+ menu.AddItem(new GUIContent(PasteValuesOnlyContent.text, $"Copy matching serialized fields from \"{snapshot.sourcePropertyPath}\"."), false, PasteValuesOnly, target);
+ }
+ else
+ {
+ menu.AddDisabledItem(PasteValuesOnlyContent);
+ }
+ if (canPasteType)
+ {
+ menu.AddItem(new GUIContent(PasteTypeAndValuesContent.text, $"Replace this reference with a copy of \"{snapshot.sourcePropertyPath}\"."), false, PasteTypeAndValues, target);
+ }
+ else
+ {
+ menu.AddDisabledItem(PasteTypeAndValuesContent);
+ }
- bool hasInstance = clonedProperty.managedReferenceValue != null;
- if (hasInstance)
- {
- menu.AddItem(NewInstanceContent, false, NewInstance, clonedProperty);
- menu.AddItem(ResetAndNewInstanceContent, false, ResetAndNewInstance, clonedProperty);
- }
- else
- {
- menu.AddDisabledItem(NewInstanceContent);
- menu.AddDisabledItem(ResetAndNewInstanceContent);
- }
+ menu.AddSeparator("");
+ if (hasInstances)
+ {
+ menu.AddItem(NewInstanceContent, false, NewInstance, target);
+ menu.AddItem(ResetAndNewInstanceContent, false, ResetAndNewInstance, target);
+ }
+ else
+ {
+ menu.AddDisabledItem(NewInstanceContent);
+ menu.AddDisabledItem(ResetAndNewInstanceContent);
}
}
private static void Copy (object customData)
{
- if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanCopy(property))
+ ((DeferredManagedReferenceTarget)customData).TryExecute(properties =>
{
- return;
- }
+ if (properties.Length != 1 || !CanCopy(properties[0]))
+ {
+ return false;
+ }
- object value = property.managedReferenceValue;
- var snapshot = new ClipboardSnapshot
- {
- sourcePropertyPath = property.propertyPath,
- typeName = value?.GetType().AssemblyQualifiedName,
- json = (value != null) ? JsonUtility.ToJson(value) : string.Empty
- };
- SessionState.SetString(ClipboardKey, JsonUtility.ToJson(snapshot));
+ SerializedProperty property = properties[0];
+ object value = property.managedReferenceValue;
+ var snapshot = new ClipboardSnapshot
+ {
+ sourcePropertyPath = property.propertyPath,
+ typeName = value?.GetType().AssemblyQualifiedName,
+ json = (value != null) ? JsonUtility.ToJson(value) : string.Empty
+ };
+ SessionState.SetString(ClipboardKey, JsonUtility.ToJson(snapshot));
+ return true;
+ });
}
private static void PasteValuesOnly (object customData)
{
- ClipboardSnapshot snapshot = ReadClipboard();
- if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanPasteValuesOnly(property, snapshot))
+ ((DeferredManagedReferenceTarget)customData).TryExecute(properties =>
{
- return;
- }
-
- UnityEngine.Object targetObject = property.serializedObject.targetObject;
- Undo.RecordObject(targetObject, PasteValuesOnlyContent.text);
- JsonUtility.FromJsonOverwrite(snapshot.json, property.managedReferenceValue);
+ ClipboardSnapshot snapshot = ReadClipboard();
+ if (properties.Length != 1 || !CanPasteValuesOnly(properties[0], snapshot))
+ {
+ return false;
+ }
- // The live instance was changed without a SerializedProperty setter, so record persistence and refresh the Inspector cache.
- PrefabUtility.RecordPrefabInstancePropertyModifications(targetObject);
- if (targetObject is ScriptableObject)
- {
- EditorUtility.SetDirty(targetObject);
- }
- property.serializedObject.Update();
+ SerializedProperty property = properties[0];
+ UnityEngine.Object targetObject = property.serializedObject.targetObject;
+ Undo.IncrementCurrentGroup();
+ int group = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName(PasteValuesOnlyContent.text);
+ try
+ {
+ Undo.RecordObject(targetObject, PasteValuesOnlyContent.text);
+ JsonUtility.FromJsonOverwrite(snapshot.json, property.managedReferenceValue);
+
+ // A live overwrite needs explicit persistence because it bypasses SerializedProperty assignment.
+ PrefabUtility.RecordPrefabInstancePropertyModifications(targetObject);
+ if (targetObject is ScriptableObject)
+ {
+ EditorUtility.SetDirty(targetObject);
+ }
+ property.serializedObject.Update();
+ }
+ finally
+ {
+ Undo.CollapseUndoOperations(group);
+ }
+ return true;
+ });
}
private static void PasteTypeAndValues (object customData)
{
- ClipboardSnapshot snapshot = ReadClipboard();
- if (!TryPrepareSingleTargetProperty(customData, out SerializedProperty property) || !CanPasteTypeAndValues(property, snapshot, out Type copiedType))
+ ((DeferredManagedReferenceTarget)customData).TryExecute(properties =>
{
- return;
- }
+ ClipboardSnapshot snapshot = ReadClipboard();
+ if (properties.Length != 1 || !CanPasteTypeAndValues(properties[0], snapshot, out Type copiedType))
+ {
+ return false;
+ }
- // A copied null has no type name; an unresolvable non-null type never reaches this assignment.
- object value = (copiedType != null) ? JsonUtility.FromJson(snapshot.json, copiedType) : null;
- Undo.SetCurrentGroupName(PasteTypeAndValuesContent.text);
- property.managedReferenceValue = value;
- property.serializedObject.ApplyModifiedProperties();
- property.serializedObject.Update();
+ // A copied null has no type name; an unresolvable non-null type never reaches this assignment.
+ object value = (copiedType != null) ? JsonUtility.FromJson(snapshot.json, copiedType) : null;
+ ManagedReferenceUtility.ApplyManagedReferences(properties, new[] { value }, PasteTypeAndValuesContent.text, false);
+ return true;
+ });
}
private static ClipboardSnapshot ReadClipboard ()
@@ -182,43 +219,38 @@ private static bool CanPasteTypeAndValues (SerializedProperty property, Clipboar
return fieldType != null && fieldType.IsAssignableFrom(copiedType);
}
- private static bool TryPrepareSingleTargetProperty (object customData, out SerializedProperty property)
- {
- var (serializedObject, propertyPath) = ((SerializedObject, string))customData;
- property = null;
- if (serializedObject.targetObject == null || serializedObject.isEditingMultipleObjects)
- {
- return false;
- }
-
- // Apply pending Inspector edits before reading the live instance or refreshing the serialized cache.
- serializedObject.ApplyModifiedProperties();
- serializedObject.Update();
- property = serializedObject.FindProperty(propertyPath);
- return property != null && property.propertyType == SerializedPropertyType.ManagedReference;
- }
-
private static void NewInstance (object customData)
{
- SerializedProperty property = (SerializedProperty)customData;
- string json = JsonUtility.ToJson(property.managedReferenceValue);
-
- Undo.RecordObject(property.serializedObject.targetObject, "New Instance");
- property.managedReferenceValue = JsonUtility.FromJson(json, property.managedReferenceValue.GetType());
- property.serializedObject.ApplyModifiedProperties();
-
- Debug.Log($"Create new instance of \"{property.propertyPath}\".");
+ ReplaceInstances((DeferredManagedReferenceTarget)customData, false);
}
private static void ResetAndNewInstance (object customData)
{
- SerializedProperty property = (SerializedProperty)customData;
-
- Undo.RecordObject(property.serializedObject.targetObject, "Reset and New Instance");
- property.managedReferenceValue = Activator.CreateInstance(property.managedReferenceValue.GetType());
- property.serializedObject.ApplyModifiedProperties();
+ ReplaceInstances((DeferredManagedReferenceTarget)customData, true);
+ }
- Debug.Log($"Reset property and created new instance of \"{property.propertyPath}\".");
+ private static void ReplaceInstances (DeferredManagedReferenceTarget target, bool reset)
+ {
+ target.TryExecute(properties =>
+ {
+ foreach (SerializedProperty property in properties)
+ {
+ if (!property.editable || property.managedReferenceValue == null)
+ {
+ return false;
+ }
+ }
+ var values = new object[properties.Length];
+ for (int i = 0; i < properties.Length; i++)
+ {
+ object current = properties[i].managedReferenceValue;
+ values[i] = reset ? Activator.CreateInstance(current.GetType()) :
+ ManagedReferenceUtility.CreateManagedReference(current, current.GetType());
+ }
+ ManagedReferenceUtility.ApplyManagedReferences(properties, values,
+ reset ? ResetAndNewInstanceContent.text : NewInstanceContent.text, false);
+ return true;
+ });
}
}
}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs
index dda4bd1..3628175 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs
@@ -11,26 +11,56 @@ public static class ManagedReferenceUtility
public static object SetManagedReference (this SerializedProperty property, Type type)
{
- object result = null;
-
#if UNITY_2021_3_OR_NEWER
- // NOTE: managedReferenceValue getter is available only in Unity 2021.3 or later.
- if ((type != null) && (property.managedReferenceValue != null))
- {
- // Restore an previous values from json.
- string json = JsonUtility.ToJson(property.managedReferenceValue);
- result = JsonUtility.FromJson(json, type);
- }
+ object result = CreateManagedReference(property.managedReferenceValue, type);
+#else
+ object result = (type != null) ? Activator.CreateInstance(type) : null;
#endif
+ property.managedReferenceValue = result;
+ return result;
+ }
- if (result == null)
+ internal static object CreateManagedReference (object previousValue, Type type)
+ {
+ if (type == null)
{
- result = (type != null) ? Activator.CreateInstance(type) : null;
+ return null;
}
+ if (previousValue != null)
+ {
+ // Keep the existing field-name matching semantics for both type changes and same-type replacement.
+ object restored = JsonUtility.FromJson(JsonUtility.ToJson(previousValue), type);
+ if (restored != null)
+ {
+ return restored;
+ }
+ }
+ return Activator.CreateInstance(type);
+ }
- property.managedReferenceValue = result;
- return result;
-
+ /// Applies previously validated and constructed replacements as one Undo operation.
+ /// Properties and their SerializedObjects are borrowed from the enclosing command scope.
+ internal static void ApplyManagedReferences (SerializedProperty[] properties, object[] values, string undoName, bool expandAssignedValues)
+ {
+ Undo.IncrementCurrentGroup();
+ int group = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName(undoName);
+ try
+ {
+ for (int i = 0; i < properties.Length; i++)
+ {
+ properties[i].managedReferenceValue = values[i];
+ if (expandAssignedValues)
+ {
+ properties[i].isExpanded = values[i] != null;
+ }
+ properties[i].serializedObject.ApplyModifiedProperties();
+ }
+ }
+ finally
+ {
+ Undo.CollapseUndoOperations(group);
+ }
}
public static Type GetType (string typeName)
@@ -46,4 +76,4 @@ public static Type GetType (string typeName)
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
index 879fda8..571d147 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs
@@ -36,9 +36,6 @@ private sealed class TypeChangeState
private readonly Dictionary typePopups = new Dictionary();
private readonly Dictionary typeNameCaches = new Dictionary();
- private SerializedProperty targetProperty;
- private TypeChangeState targetTypeChangeState;
-
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
{
int controlID = GUIUtility.GetControlID(property.propertyPath.GetHashCode(), FocusType.Passive);
@@ -57,113 +54,120 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte
#endif
EditorGUI.BeginProperty(position, label, property);
-
- // A type change can recreate the drawer. Keep its pending notification with the owning GUI control.
- if (typeChangeState.Pending)
+ try
{
- typeChangeState.Pending = false;
- GUI.changed = true;
- }
-
- if (isManagedReference)
- {
- // Render label first to avoid label overlap for lists
- Rect foldoutLabelRect = new Rect(position);
- foldoutLabelRect.height = EditorGUIUtility.singleLineHeight;
-
- // NOTE: IndentedRect should be disabled as it causes extra indentation.
- //foldoutLabelRect = EditorGUI.IndentedRect(foldoutLabelRect);
- Rect popupPosition = EditorGUI.PrefixLabel(foldoutLabelRect, label);
-
- // Draw the subclass selector popup.
- if (EditorGUI.DropdownButton(popupPosition, GetTypeName(property), FocusType.Keyboard))
+ // A type change can recreate the drawer. Keep its pending notification with the owning GUI control.
+ if (typeChangeState.Pending)
{
- TypePopupCache popup = GetTypePopup(property);
- targetProperty = property;
- targetTypeChangeState = typeChangeState;
- popup.TypePopup.Show(popupPosition);
+ typeChangeState.Pending = false;
+ GUI.changed = true;
}
- // Draw the foldout.
- if (!string.IsNullOrEmpty(property.managedReferenceFullTypename))
+ if (isManagedReference)
{
- Rect foldoutRect = new Rect(position);
- foldoutRect.height = EditorGUIUtility.singleLineHeight;
+ // Render label first to avoid label overlap for lists
+ Rect foldoutLabelRect = new Rect(position);
+ foldoutLabelRect.height = EditorGUIUtility.singleLineHeight;
+
+ // NOTE: IndentedRect should be disabled as it causes extra indentation.
+ //foldoutLabelRect = EditorGUI.IndentedRect(foldoutLabelRect);
+ Rect popupPosition = EditorGUI.PrefixLabel(foldoutLabelRect, label);
+
+ // Draw the subclass selector popup.
+ if (EditorGUI.DropdownButton(popupPosition, GetTypeName(property), FocusType.Keyboard))
+ {
+ TypePopupCache popup = GetTypePopup(property);
+ DeferredManagedReferenceTarget target = DeferredManagedReferenceTarget.Capture(property);
+ if (target != null)
+ {
+ popup.TypePopup.ShowForSelection(popupPosition, item => SelectType(target, item.Type, typeChangeState));
+ }
+ return;
+ }
+
+ // Draw the foldout.
+ if (!string.IsNullOrEmpty(property.managedReferenceFullTypename))
+ {
+ Rect foldoutRect = new Rect(position);
+ foldoutRect.height = EditorGUIUtility.singleLineHeight;
#if UNITY_2022_2_OR_NEWER && !UNITY_6000_0_OR_NEWER && !UNITY_2022_3
- // NOTE: Position x must be adjusted.
- // FIXME: Is there a more essential solution...?
- // The most promising is UI Toolkit, but it is currently unable to reproduce all of SubclassSelector features. (Complete provision of contextual menu, e.g.)
- // 2021.3: No adjustment
- // 2022.1: No adjustment
- // 2022.2: Adjustment required
- // 2022.3: Adjustment required
- // 2023.1: Adjustment required
- // 2023.2: Adjustment required
- // 6000.0: No adjustment
- foldoutRect.x -= 12;
+ // NOTE: Position x must be adjusted.
+ // FIXME: Is there a more essential solution...?
+ // The most promising is UI Toolkit, but it is currently unable to reproduce all of SubclassSelector features. (Complete provision of contextual menu, e.g.)
+ // 2021.3: No adjustment
+ // 2022.1: No adjustment
+ // 2022.2: Adjustment required
+ // 2022.3: Adjustment required
+ // 2023.1: Adjustment required
+ // 2023.2: Adjustment required
+ // 6000.0: No adjustment
+ foldoutRect.x -= 12;
#endif
- property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, GUIContent.none, true);
- }
+ property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, GUIContent.none, true);
+ }
- // Draw property if expanded.
- if (property.isExpanded)
- {
- using (new EditorGUI.IndentLevelScope())
+ // Draw property if expanded.
+ if (property.isExpanded)
{
- if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property))
- {
- // Let Unity invoke the next drawer with its declaring-field context and native ownership.
- Rect indentedRect = position;
- float foldoutDifference = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
- indentedRect.height = EditorGUI.GetPropertyHeight(property, label, true);
- indentedRect.y += foldoutDifference;
- EditorGUI.PropertyField(indentedRect, property, label, true);
- }
- else
+ using (new EditorGUI.IndentLevelScope())
{
- // Draw the properties of the child elements.
- // NOTE: In the following code, since the foldout layout isn't working properly, I'll iterate through the properties of the child elements myself.
- // EditorGUI.PropertyField(position, property, GUIContent.none, true);
-
- Rect childPosition = position;
- childPosition.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
- foreach (SerializedProperty childProperty in property.GetChildProperties())
+ if (UnityPropertyDrawerUtility.HasNextPropertyDrawer(property))
+ {
+ // Let Unity invoke the next drawer with its declaring-field context and native ownership.
+ Rect indentedRect = position;
+ float foldoutDifference = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
+ indentedRect.height = EditorGUI.GetPropertyHeight(property, label, true);
+ indentedRect.y += foldoutDifference;
+ EditorGUI.PropertyField(indentedRect, property, label, true);
+ }
+ else
{
- using (childProperty)
+ // Draw the properties of the child elements.
+ // NOTE: In the following code, since the foldout layout isn't working properly, I'll iterate through the properties of the child elements myself.
+ // EditorGUI.PropertyField(position, property, GUIContent.none, true);
+
+ Rect childPosition = position;
+ childPosition.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
+ foreach (SerializedProperty childProperty in property.GetChildProperties())
{
- float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true);
- childPosition.height = height;
- using (var changeCheck = new EditorGUI.ChangeCheckScope())
+ using (childProperty)
{
- EditorGUI.PropertyField(childPosition, childProperty, true);
- // A child can replace its structure and invalidate the active iterator.
- if (changeCheck.changed)
+ float height = EditorGUI.GetPropertyHeight(childProperty, new GUIContent(childProperty.displayName, childProperty.tooltip), true);
+ childPosition.height = height;
+ using (var changeCheck = new EditorGUI.ChangeCheckScope())
{
- break;
+ EditorGUI.PropertyField(childPosition, childProperty, true);
+ // A child can replace its structure and invalidate the active iterator.
+ if (changeCheck.changed)
+ {
+ break;
+ }
}
- }
- childPosition.y += height + EditorGUIUtility.standardVerticalSpacing;
+ childPosition.y += height + EditorGUIUtility.standardVerticalSpacing;
+ }
}
}
}
- }
#if UNITY_6000_0_OR_NEWER
- // TextArea can defer its prefix label during Layout. Resolve it inside
- // this drawer's IMGUI context before UI Toolkit starts another one.
- GUIUtility.GetControlID(FocusType.Passive);
+ // TextArea can defer its prefix label during Layout. Resolve it inside
+ // this drawer's IMGUI context before UI Toolkit starts another one.
+ GUIUtility.GetControlID(FocusType.Passive);
#endif
+ }
+ }
+ else
+ {
+ EditorGUI.LabelField(position, label, IsNotManagedReferenceLabel);
}
}
- else
+ finally
{
- EditorGUI.LabelField(position, label, IsNotManagedReferenceLabel);
+ EditorGUI.EndProperty();
}
-
- EditorGUI.EndProperty();
}
private TypePopupCache GetTypePopup (SerializedProperty property)
@@ -182,29 +186,36 @@ private TypePopupCache GetTypePopup (SerializedProperty property)
MaxTypePopupLineCount,
state
);
- popup.OnItemSelected += item =>
- {
- Type type = item.Type;
+ result = new TypePopupCache(popup, state);
+ typePopups.Add(managedReferenceFieldTypename, result);
+ }
+ return result;
+ }
- // Apply changes to individual serialized objects.
- foreach (var targetObject in targetProperty.serializedObject.targetObjects)
+ private static void SelectType (DeferredManagedReferenceTarget target, Type type, TypeChangeState changeState)
+ {
+ if (target.TryExecute(properties =>
+ {
+ for (int i = 0; i < properties.Length; i++)
+ {
+ Type fieldType = ManagedReferenceUtility.GetType(properties[i].managedReferenceFieldTypename.Replace('/', '+'));
+ if (!properties[i].editable || (type != null && (fieldType == null || !fieldType.IsAssignableFrom(type))))
{
- SerializedObject individualObject = new SerializedObject(targetObject);
- SerializedProperty individualProperty = individualObject.FindProperty(targetProperty.propertyPath);
- object obj = individualProperty.SetManagedReference(type);
- individualProperty.isExpanded = (obj != null);
-
- individualObject.ApplyModifiedProperties();
- individualObject.Update();
+ return false;
}
+ }
- targetTypeChangeState.Pending = true;
- };
-
- result = new TypePopupCache(popup, state);
- typePopups.Add(managedReferenceFieldTypename, result);
+ var values = new object[properties.Length];
+ for (int i = 0; i < properties.Length; i++)
+ {
+ values[i] = ManagedReferenceUtility.CreateManagedReference(properties[i].managedReferenceValue, type);
+ }
+ ManagedReferenceUtility.ApplyManagedReferences(properties, values, "Change Managed Reference Type", true);
+ return true;
+ }))
+ {
+ changeState.Pending = true;
}
- return result;
}
private GUIContent GetTypeName (SerializedProperty property)
From 77177de2d3c8d77bfdb46c89ff66b14f54b8850d Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 04:26:38 +0900
Subject: [PATCH 5/6] fix(editor): observe collection order only along indexed
paths
Why
A host or inline object can implement IList for unrelated purposes. Capturing
a normal reference field must not invoke that object's collection interface.
What Changed
Capture immediate collection elements only when the serialized property path
actually traverses an array or List index.
Impact
Normal field ancestry uses identity comparison without calling unrelated user
Count or indexer code; actual collection retarget detection is preserved.
---
.../Editor/DeferredManagedReferenceTarget.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
index 8c4965c..e228136 100644
--- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
+++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DeferredManagedReferenceTarget.cs
@@ -46,10 +46,10 @@ private sealed class ContainerObservation
private readonly object value;
private readonly object[] elements;
- internal ContainerObservation (object value)
+ internal ContainerObservation (object value, bool captureSequence)
{
this.value = SnapshotValue(value);
- if (value is IList list)
+ if (captureSequence && value is IList list)
{
elements = new object[list.Count];
for (int i = 0; i < elements.Length; i++)
@@ -134,7 +134,8 @@ internal static RuntimePath Capture (UnityEngine.Object host, string propertyPat
currentType = field.FieldType;
}
- containers.Add(new ContainerObservation(current));
+ // A field owner may implement IList for unrelated purposes; only index steps observe its sequence.
+ containers.Add(new ContainerObservation(current, step.Field == null));
steps.Add(step);
current = step.Read(current);
if (current != null)
From b6dec3782e109a0a4732c06dcd946fda1ee6f92e Mon Sep 17 00:00:00 2001
From: Makihiro <13536348+mackysoft@users.noreply.github.com>
Date: Fri, 16 Oct 2026 04:48:28 +0900
Subject: [PATCH 6/6] test(editor): verify deferred managed reference commands
Why
Menus must preserve the caller's edits and operate on the intended serialized
reference after deferral, while retaining normal Undo and persistence behavior.
What Changed
Exercise context commands and real popup selections for buffered edits, valid
nested targets, stale references, collection correspondence and multiple hosts.
Verify Prefab overrides and source preservation for replacement and live paste,
including Undo, Redo and saved-scene readback.
Impact
Consumer tests detect lost edits and redirected callbacks without depending on
private target-token or cache structure. Test assets stay outside the package.
---
.../Editor/DeferredMenuLifecycleTests.cs | 480 ++++++++++++++++++
.../Editor/DeferredMenuLifecycleTests.cs.meta | 11 +
.../Editor/DeferredPrefabLifecycleTests.cs | 158 ++++++
.../DeferredPrefabLifecycleTests.cs.meta | 11 +
.../Editor/DeferredReferenceTestAsset.cs | 71 +++
.../Editor/DeferredReferenceTestAsset.cs.meta | 11 +
.../Editor/DeferredReferenceTestAssetBase.cs | 14 +
.../DeferredReferenceTestAssetBase.cs.meta | 11 +
.../Editor/DeferredSelectionLifecycleTests.cs | 297 +++++++++++
.../DeferredSelectionLifecycleTests.cs.meta | 11 +
.../Editor/DeferredSelectionWindow.cs | 62 +++
.../Editor/DeferredSelectionWindow.cs.meta | 11 +
.../Editor/UnrelatedListReferenceTestAsset.cs | 54 ++
.../UnrelatedListReferenceTestAsset.cs.meta | 11 +
14 files changed, 1213 insertions(+)
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs
create mode 100644 Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs
new file mode 100644
index 0000000..99e8e0d
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs
@@ -0,0 +1,480 @@
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using Asset = MackySoft.SerializeReferenceExtensions.Tests.DeferredReferenceTestAsset;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DeferredMenuLifecycleTests
+ {
+ private const string NewInstance = "New Instance";
+ private const string ResetInstance = "Reset and New Instance";
+ private const string PasteType = "Paste Type and Values";
+ private const string PasteValues = "Paste Values Only";
+ private Asset target;
+ private Asset other;
+ private Asset source;
+ private string assetPath;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ Undo.ClearAll();
+ assetPath = null;
+ target = ScriptableObject.CreateInstance();
+ other = ScriptableObject.CreateInstance();
+ source = ScriptableObject.CreateInstance();
+ source.reference = new Asset.Alpha(31);
+ using (var serialized = new SerializedObject(source))
+ {
+ SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), CopyLabel("reference"));
+ }
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ Undo.ClearAll();
+ if (!string.IsNullOrEmpty(assetPath))
+ {
+ Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True);
+ }
+ else if (target != null)
+ {
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+ if (other != null)
+ {
+ UnityEngine.Object.DestroyImmediate(other);
+ }
+ if (source != null)
+ {
+ UnityEngine.Object.DestroyImmediate(source);
+ }
+ }
+
+ [Test]
+ public void OpeningAndCancellingMenu_PreservesCallerEditsWithoutReplacingReference ()
+ {
+ Asset.INode original = target.reference;
+ using (var serialized = new SerializedObject(target))
+ {
+ StageEdits(serialized);
+ Open(serialized, "reference");
+ Assert.That(target.reference, Is.SameAs(original));
+ serialized.ApplyModifiedProperties();
+ AssertAlpha(target.reference, 93, 94);
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ }
+ }
+
+ [TestCase(NewInstance, false)]
+ [TestCase(NewInstance, true)]
+ [TestCase(ResetInstance, false)]
+ [TestCase(ResetInstance, true)]
+ public void InstanceCommand_PreservesEditsStagedBeforeOrAfterOpening (string command, bool afterOpening)
+ {
+ Asset.INode original = target.reference;
+ using (var serialized = new SerializedObject(target))
+ {
+ if (!afterOpening)
+ {
+ StageEdits(serialized);
+ }
+ GenericMenu menu = Open(serialized, "reference");
+ if (afterOpening)
+ {
+ StageEdits(serialized);
+ }
+ SerializedPropertyContextMenu.Execute(menu, command);
+ Assert.That(target.reference, Is.Not.SameAs(original));
+ AssertAlpha(target.reference, command == NewInstance ? 93 : 7, command == NewInstance ? 94 : 8);
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ using (SerializedProperty property = serialized.FindProperty("unrelated"))
+ {
+ Assert.That(property.intValue, Is.EqualTo(88), "The caller's wrapper remains usable and refreshed.");
+ }
+ }
+ }
+
+ [TestCase("elements.Array.data[1]", 23)]
+ [TestCase("parent.reference", 17)]
+ [TestCase("inlineItems.Array.data[0].reference", 19)]
+ [TestCase("slots.Array.data[0].reference", 41)]
+ [TestCase("inherited.reference", 19)]
+ public void UnchangedNestedTarget_NewInstanceReplacesOnlyItsValue (string path, int amount)
+ {
+ using (var serialized = new SerializedObject(target))
+ {
+ Asset.INode original = ValueAt(serialized, path);
+ SerializedPropertyContextMenu.Execute(Open(serialized, path), NewInstance);
+ Asset.INode replacement = ValueAt(serialized, path);
+ Assert.That(replacement, Is.Not.SameAs(original));
+ AssertAlpha(replacement, amount, amount + 1);
+ }
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void UnchangedNullCollectionOrInlinePrefix_AcceptsPaste (bool nullInlinePrefix)
+ {
+ string path = "elements.Array.data[0]";
+ if (nullInlinePrefix)
+ {
+ target.parent = null;
+ path = "parent.reference";
+ }
+ using (var serialized = new SerializedObject(target))
+ {
+ SerializedPropertyContextMenu.Execute(Open(serialized, path), PasteType);
+ AssertAlpha(ValueAt(serialized, path), 31, 32);
+ }
+ Assert.That(target.elements[2], Is.Null);
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ }
+
+ [TestCase(NewInstance)]
+ [TestCase(ResetInstance)]
+ [TestCase(PasteValues)]
+ [TestCase(PasteType)]
+ public void ReorderedReferences_DeferredEditCancelsWithoutEditingEitherElement (string command)
+ {
+ Asset.INode first = target.elements[1];
+ Asset.INode second = target.elements[3];
+ using (var serialized = new SerializedObject(target))
+ {
+ GenericMenu menu = Open(serialized, "elements.Array.data[1]");
+ target.elements[1] = second;
+ target.elements[3] = first;
+ SerializedPropertyContextMenu.Execute(menu, command);
+ }
+ Assert.That(target.elements[1], Is.SameAs(second));
+ Assert.That(target.elements[3], Is.SameAs(first));
+ AssertAlpha(first, 23, 24);
+ Assert.That(((Asset.Beta)second).amount, Is.EqualTo(47));
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ }
+
+ [TestCase("removed element")]
+ [TestCase("replacement collection")]
+ [TestCase("inline ancestor")]
+ [TestCase("managed ancestor")]
+ [TestCase("equal target")]
+ [TestCase("changed type")]
+ [TestCase("inline reorder")]
+ [TestCase("value reorder")]
+ public void LostCorrespondence_NewInstanceSafelyCancels (string change)
+ {
+ string path = "reference";
+ Action mutate;
+ Action assertRetained;
+ switch (change)
+ {
+ case "removed element":
+ path = "elements.Array.data[3]";
+ Asset.INode survivor = target.elements[1];
+ mutate = () => target.elements.RemoveAt(3);
+ assertRetained = () =>
+ {
+ Assert.That(target.elements, Has.Count.EqualTo(3));
+ Assert.That(target.elements[1], Is.SameAs(survivor));
+ };
+ break;
+ case "replacement collection":
+ path = "elements.Array.data[1]";
+ var collection = new List(target.elements);
+ Asset.INode element = collection[1];
+ mutate = () => target.elements = collection;
+ assertRetained = () =>
+ {
+ Assert.That(target.elements, Is.SameAs(collection));
+ Assert.That(target.elements[1], Is.SameAs(element));
+ };
+ break;
+ case "inline ancestor":
+ path = "parent.reference";
+ var inline = new Asset.Parent { reference = target.parent.reference };
+ Asset.INode inlineValue = inline.reference;
+ mutate = () => target.parent = inline;
+ assertRetained = () =>
+ {
+ Assert.That(target.parent, Is.SameAs(inline));
+ Assert.That(target.parent.reference, Is.SameAs(inlineValue));
+ };
+ break;
+ case "managed ancestor":
+ path = "managedParent.reference";
+ var managed = new Asset.Parent { reference = target.managedParent.reference };
+ Asset.INode managedValue = managed.reference;
+ mutate = () => target.managedParent = managed;
+ assertRetained = () =>
+ {
+ Assert.That(target.managedParent, Is.SameAs(managed));
+ Assert.That(target.managedParent.reference, Is.SameAs(managedValue));
+ };
+ break;
+ case "equal target":
+ var equal = new Asset.Alpha(17);
+ mutate = () => target.reference = equal;
+ assertRetained = () => Assert.That(target.reference, Is.SameAs(equal));
+ break;
+ case "changed type":
+ var changedType = new Asset.Beta(47);
+ mutate = () => target.reference = changedType;
+ assertRetained = () => Assert.That(target.reference, Is.SameAs(changedType));
+ break;
+ case "inline reorder":
+ path = "inlineItems.Array.data[0].reference";
+ Asset.Parent first = target.inlineItems[0];
+ Asset.Parent second = target.inlineItems[1];
+ Asset.INode secondValue = second.reference;
+ mutate = () => { target.inlineItems[0] = second; target.inlineItems[1] = first; };
+ assertRetained = () =>
+ {
+ Assert.That(target.inlineItems[0], Is.SameAs(second));
+ Assert.That(second.reference, Is.SameAs(secondValue));
+ AssertAlpha(second.reference, 29, 30);
+ Assert.That(target.inlineItems[1], Is.SameAs(first));
+ };
+ break;
+ case "value reorder":
+ path = "slots.Array.data[0].reference";
+ var shared = new Asset.Alpha(41);
+ target.slots = new[] { new Asset.Slot { marker = 1, reference = shared }, new Asset.Slot { marker = 2, reference = shared } };
+ mutate = () => Array.Reverse(target.slots);
+ assertRetained = () =>
+ {
+ Assert.That(target.slots[0].marker, Is.EqualTo(2));
+ Assert.That(target.slots[1].marker, Is.EqualTo(1));
+ Assert.That(target.slots[0].reference, Is.SameAs(shared));
+ Assert.That(target.slots[1].reference, Is.SameAs(shared));
+ };
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(change), change, "Unknown correspondence change.");
+ }
+ using (var serialized = new SerializedObject(target))
+ {
+ GenericMenu menu = Open(serialized, path);
+ mutate();
+ SerializedPropertyContextMenu.Execute(menu, NewInstance);
+ }
+ assertRetained();
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ }
+
+ [Test]
+ public void NullElementReorderCancellation_LeavesUnrelatedCallerBufferUnappliedAndIntact ()
+ {
+ Asset.INode first = target.elements[1];
+ Asset.INode second = target.elements[3];
+ using (var serialized = new SerializedObject(target))
+ {
+ GenericMenu menu = Open(serialized, "elements.Array.data[0]");
+ using (SerializedProperty pending = serialized.FindProperty("unrelated"))
+ {
+ pending.intValue = 105;
+ }
+ target.elements[1] = second;
+ target.elements[3] = first;
+ SerializedPropertyContextMenu.Execute(menu, PasteType);
+
+ Assert.That(target.elements[0], Is.Null);
+ Assert.That(target.elements[1], Is.SameAs(second));
+ Assert.That(target.elements[3], Is.SameAs(first));
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ Assert.That(serialized.hasModifiedProperties, Is.True);
+ using (SerializedProperty pending = serialized.FindProperty("unrelated"))
+ {
+ Assert.That(pending.intValue, Is.EqualTo(105));
+ }
+ }
+ }
+
+ [Test]
+ public void StaleCopy_DoesNotReplaceThePreviousClipboardSnapshot ()
+ {
+ using (var serialized = new SerializedObject(target))
+ {
+ GenericMenu menu = Open(serialized, "elements.Array.data[1]");
+ target.elements[1] = new Asset.Beta(66);
+ SerializedPropertyContextMenu.Execute(menu, CopyLabel("elements.Array.data[1]"));
+ }
+ using (var serialized = new SerializedObject(other))
+ {
+ SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), PasteType);
+ }
+ AssertAlpha(other.reference, 31, 32);
+ Assert.That(((Asset.Beta)target.elements[1]).amount, Is.EqualTo(66));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void DisposedOriginOrDestroyedHost_DeferredCommandCancels (bool destroyHost)
+ {
+ Asset.INode original = target.reference;
+ var serialized = new SerializedObject(target);
+ try
+ {
+ GenericMenu menu = Open(serialized, "reference");
+ if (destroyHost)
+ {
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+ else
+ {
+ serialized.Dispose();
+ serialized = null;
+ }
+ SerializedPropertyContextMenu.Execute(menu, NewInstance);
+ if (!destroyHost)
+ {
+ Assert.That(target.reference, Is.SameAs(original));
+ }
+ AssertAlpha(original, 17, 18);
+ Assert.That(other.unrelated, Is.EqualTo(81));
+ }
+ finally
+ {
+ serialized?.Dispose();
+ }
+ }
+
+ [TestCase(NewInstance)]
+ [TestCase(ResetInstance)]
+ public void HeterogeneousHosts_KeepTheirOwnTypeValuesAndIndependentInstances (string command)
+ {
+ target.reference = new Asset.Alpha(23);
+ other.reference = new Asset.Beta(47);
+ Asset.INode original = target.reference;
+ Asset.INode otherOriginal = other.reference;
+ using (var serialized = new SerializedObject(new UnityEngine.Object[] { target, other }))
+ {
+ using (SerializedProperty pending = serialized.FindProperty("unrelated"))
+ {
+ pending.intValue = 88;
+ }
+ SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), command);
+ }
+ Assert.That(target.reference, Is.Not.SameAs(original));
+ Assert.That(other.reference, Is.Not.SameAs(otherOriginal));
+ Assert.That(target.reference, Is.Not.SameAs(other.reference));
+ AssertAlpha(target.reference, command == NewInstance ? 23 : 7, command == NewInstance ? 24 : 8);
+ Assert.That(other.reference, Is.TypeOf());
+ Assert.That(((Asset.Beta)other.reference).amount, Is.EqualTo(command == NewInstance ? 47 : 11));
+ Assert.That(((Asset.Beta)other.reference).payload.value, Is.EqualTo(command == NewInstance ? 48 : 12));
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ Assert.That(other.unrelated, Is.EqualTo(88));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void OneInvalidHost_CancelsTheWholeCommand (bool destroyHost)
+ {
+ Asset.INode original = target.reference;
+ var replacement = new Asset.Beta(47);
+ using (var serialized = new SerializedObject(new UnityEngine.Object[] { target, other }))
+ {
+ GenericMenu menu = Open(serialized, "reference");
+ if (destroyHost)
+ {
+ UnityEngine.Object.DestroyImmediate(other);
+ }
+ else
+ {
+ other.reference = replacement;
+ }
+ SerializedPropertyContextMenu.Execute(menu, NewInstance);
+ }
+ Assert.That(target.reference, Is.SameAs(original));
+ AssertAlpha(target.reference, 17, 18);
+ if (!destroyHost)
+ {
+ Assert.That(other.reference, Is.SameAs(replacement));
+ }
+ }
+
+ [Test]
+ public void NewInstance_PersistsBufferedScalarAndChildValuesForAssetReload ()
+ {
+ assetPath = "Assets/__DeferredInstance_" + Guid.NewGuid().ToString("N") + ".asset";
+ AssetDatabase.CreateAsset(target, assetPath);
+ AssetDatabase.SaveAssets();
+ Asset.INode original = target.reference;
+ using (var serialized = new SerializedObject(target))
+ {
+ StageEdits(serialized);
+ SerializedPropertyContextMenu.Execute(Open(serialized, "reference"), NewInstance);
+ }
+ Assert.That(target.reference, Is.Not.SameAs(original));
+ AssetDatabase.SaveAssets();
+ Resources.UnloadAsset(target);
+ target = AssetDatabase.LoadAssetAtPath(assetPath);
+ Assert.That(target, Is.Not.Null);
+ AssertAlpha(target.reference, 93, 94);
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ }
+
+ [TestCase("reference", 17)]
+ [TestCase("parent.reference", 23)]
+ public void OrdinaryHostOrInlineOwnerWithUnrelatedListApi_CanEditSerializedReference (string path, int amount)
+ {
+ var owner = ScriptableObject.CreateInstance();
+ try
+ {
+ using (var serialized = new SerializedObject(owner))
+ {
+ Asset.INode original = ValueAt(serialized, path);
+ SerializedPropertyContextMenu.Execute(Open(serialized, path), NewInstance);
+ Asset.INode replacement = ValueAt(serialized, path);
+ Assert.That(replacement, Is.Not.SameAs(original));
+ AssertAlpha(replacement, amount, amount + 1);
+ }
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(owner);
+ }
+ }
+
+ private static void StageEdits (SerializedObject serialized)
+ {
+ using (SerializedProperty amount = serialized.FindProperty("reference.amount"))
+ using (SerializedProperty child = serialized.FindProperty("reference.payload.value"))
+ using (SerializedProperty unrelated = serialized.FindProperty("unrelated"))
+ {
+ amount.intValue = 93;
+ child.intValue = 94;
+ unrelated.intValue = 88;
+ }
+ }
+
+ private static GenericMenu Open (SerializedObject serialized, string path)
+ {
+ using (SerializedProperty property = serialized.FindProperty(path))
+ {
+ return SerializedPropertyContextMenu.Open(property);
+ }
+ }
+
+ private static Asset.INode ValueAt (SerializedObject serialized, string path)
+ {
+ using (SerializedProperty property = serialized.FindProperty(path))
+ {
+ return (Asset.INode)property.managedReferenceValue;
+ }
+ }
+
+ private static string CopyLabel (string path) => "Copy \"" + path + "\" property";
+
+ private static void AssertAlpha (Asset.INode value, int amount, int child)
+ {
+ Assert.That(value, Is.TypeOf());
+ Assert.That(((Asset.Alpha)value).amount, Is.EqualTo(amount));
+ Assert.That(((Asset.Alpha)value).payload.value, Is.EqualTo(child));
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta
new file mode 100644
index 0000000..26276dd
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredMenuLifecycleTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 87d97140cdbf341d4b74202e0ac6002a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs
new file mode 100644
index 0000000..4f2d24c
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DeferredPrefabLifecycleTests
+ {
+ private const string PasteType = "Paste Type and Values";
+ private const string PasteValues = "Paste Values Only";
+ private string folder;
+ private Scene scene;
+ private Scene loadedScene;
+ private GameObject prefab;
+ private GameObject instance;
+ private Type componentType;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ Undo.ClearAll();
+ folder = "Assets/__DeferredPrefab_" + Guid.NewGuid().ToString("N");
+ AssetDatabase.CreateFolder("Assets", folder.Substring("Assets/".Length));
+ scene = SceneManager.GetActiveScene();
+ // Reuse the project's public runtime example; asmdef tests cannot reference Assembly-CSharp directly.
+ componentType = Type.GetType("Example, Assembly-CSharp", true);
+ var template = new GameObject("Deferred prefab consumer " + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Component templateComponent = template.AddComponent(componentType);
+ ConfigureFood(templateComponent, "Apple", "Prefab apple", 13f);
+ prefab = PrefabUtility.SaveAsPrefabAsset(template, folder + "/Source.prefab");
+ Assert.That(prefab, Is.Not.Null);
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(template);
+ }
+ instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, scene);
+
+ var clipboardSource = new GameObject("Clipboard source");
+ try
+ {
+ Component sourceComponent = clipboardSource.AddComponent(componentType);
+ ConfigureFood(sourceComponent, "Grape", "Copied value", 77f);
+ using (var serialized = new SerializedObject(sourceComponent))
+ using (SerializedProperty property = serialized.FindProperty("foodOne"))
+ {
+ SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(property), "Copy \"foodOne\" property");
+ }
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(clipboardSource);
+ }
+ AssetDatabase.SaveAssets();
+ Undo.ClearAll();
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ Undo.ClearAll();
+ if (loadedScene.IsValid() && loadedScene.isLoaded)
+ {
+ Assert.That(EditorSceneManager.CloseScene(loadedScene, true), Is.True);
+ }
+ if (instance != null)
+ {
+ UnityEngine.Object.DestroyImmediate(instance);
+ }
+ if (!string.IsNullOrEmpty(folder))
+ {
+ Assert.That(AssetDatabase.DeleteAsset(folder), Is.True);
+ }
+ }
+
+ [TestCase(PasteType)]
+ [TestCase(PasteValues)]
+ public void Paste_RecordsPrefabOverrideAndSupportsUndoRedoAndSavedInstance (string command)
+ {
+ Component component = instance.GetComponent(componentType);
+ object original = ReadReference(component);
+ Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.False);
+ using (var serialized = new SerializedObject(component))
+ using (SerializedProperty property = serialized.FindProperty("foodOne"))
+ {
+ SerializedPropertyContextMenu.Execute(SerializedPropertyContextMenu.Open(property), command);
+ }
+ Undo.FlushUndoRecordObjects();
+ AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f);
+ Assert.That(ReadReference(component), command == PasteType ? Is.Not.SameAs(original) : Is.SameAs(original));
+ Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.True);
+ AssertSourcePrefab();
+
+ Undo.PerformUndo();
+ AssertFood(component, "Apple", "Prefab apple", 13f);
+ Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.False);
+ Undo.PerformRedo();
+ AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f);
+ Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(instance, false), Is.True);
+ AssertSourcePrefab();
+
+ string scenePath = folder + "/Instance.unity";
+ // Save a copy and read it in isolation, preserving all currently open scenes.
+ Assert.That(EditorSceneManager.SaveScene(scene, scenePath, true), Is.True);
+ loadedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Additive);
+ GameObject loadedInstance = loadedScene.GetRootGameObjects().Single(root => root.name == instance.name);
+ component = loadedInstance.GetComponent(componentType);
+ Assert.That(PrefabUtility.IsPartOfPrefabInstance(component), Is.True);
+ AssertFood(component, command == PasteType ? "Grape" : "Apple", "Copied value", 77f);
+ Assert.That(PrefabUtility.HasPrefabInstanceAnyOverrides(loadedInstance, false), Is.True);
+ AssertSourcePrefab();
+ }
+
+ private void AssertSourcePrefab ()
+ {
+ AssertFood(prefab.GetComponent(componentType), "Apple", "Prefab apple", 13f);
+ }
+
+ private static object ReadReference (Component component)
+ {
+ return component.GetType().GetField("foodOne").GetValue(component);
+ }
+
+ private static void ConfigureFood (Component component, string typeName, string name, float calories)
+ {
+ object food = Activator.CreateInstance(Type.GetType(typeName + ", Assembly-CSharp", true));
+ component.GetType().GetField("foodOne").SetValue(component, food);
+ using (var serialized = new SerializedObject(component))
+ using (SerializedProperty property = serialized.FindProperty("foodOne"))
+ using (SerializedProperty foodName = property.FindPropertyRelative("name"))
+ using (SerializedProperty kcal = property.FindPropertyRelative("kcal"))
+ {
+ foodName.stringValue = name;
+ kcal.floatValue = calories;
+ serialized.ApplyModifiedPropertiesWithoutUndo();
+ }
+ }
+
+ private static void AssertFood (Component component, string typeName, string name, float calories)
+ {
+ Assert.That(ReadReference(component).GetType().Name, Is.EqualTo(typeName));
+ using (var serialized = new SerializedObject(component))
+ using (SerializedProperty property = serialized.FindProperty("foodOne"))
+ using (SerializedProperty foodName = property.FindPropertyRelative("name"))
+ using (SerializedProperty kcal = property.FindPropertyRelative("kcal"))
+ {
+ Assert.That(foodName.stringValue, Is.EqualTo(name));
+ Assert.That(kcal.floatValue, Is.EqualTo(calories));
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta
new file mode 100644
index 0000000..4bfde49
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredPrefabLifecycleTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1c54f5845958546dcb9c817ea4bf1e60
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs
new file mode 100644
index 0000000..7d61458
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DeferredReferenceTestAsset : DeferredReferenceTestAssetBase
+ {
+ [SerializeReference, SubclassSelector] public INode reference = new Alpha(17);
+ [SerializeReference, SubclassSelector] public List elements = new List { null, new Alpha(23), null, new Beta(47) };
+ public Parent parent = new Parent { reference = new Alpha(17) };
+ [SerializeReference] public Parent managedParent = new Parent { reference = new Alpha(37) };
+ public Parent[] inlineItems = { new Parent { reference = new Alpha(19) }, new Parent { reference = new Alpha(29) } };
+ public Slot[] slots = { new Slot { marker = 1, reference = new Alpha(41) }, new Slot { marker = 2, reference = new Alpha(51) } };
+ public int unrelated = 81;
+
+ public interface INode { }
+
+ [Serializable]
+ public sealed class Payload
+ {
+ public int value;
+ }
+
+ [Serializable, AddTypeMenu("Deferred Alpha")]
+ public sealed class Alpha : INode
+ {
+ public int amount = 7;
+ public Payload payload = new Payload { value = 8 };
+
+ public Alpha () { }
+
+ public Alpha (int value)
+ {
+ amount = value;
+ payload.value = value + 1;
+ }
+ }
+
+ [Serializable, AddTypeMenu("Deferred Beta")]
+ public sealed class Beta : INode
+ {
+ public int amount = 11;
+ public Payload payload = new Payload { value = 12 };
+
+ public Beta () { }
+
+ public Beta (int value)
+ {
+ amount = value;
+ payload.value = value + 1;
+ }
+ }
+
+ [Serializable]
+ public sealed class Parent
+ {
+ [SerializeReference, SubclassSelector] public INode reference;
+ }
+
+ [Serializable]
+ public struct Slot
+ {
+ public int marker;
+ [SerializeReference, SubclassSelector] public INode reference;
+
+ public override bool Equals (object obj) => true;
+ public override int GetHashCode () => 0;
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta
new file mode 100644
index 0000000..591ea70
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAsset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 11a0523d7d9d14f12a0eb534909ed48a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs
new file mode 100644
index 0000000..cd02233
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs
@@ -0,0 +1,14 @@
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public class DeferredReferenceTestAssetBase : ScriptableObject
+ {
+ [SerializeField] private DeferredReferenceTestAsset.Parent inherited = new DeferredReferenceTestAsset.Parent
+ {
+ reference = new DeferredReferenceTestAsset.Alpha(19)
+ };
+
+ public DeferredReferenceTestAsset.INode InheritedReference => inherited.reference;
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta
new file mode 100644
index 0000000..a260026
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredReferenceTestAssetBase.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d9c38b7e893c04eecb77280d44692084
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs
new file mode 100644
index 0000000..4d020e2
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Collections;
+using System.Linq;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.TestTools;
+using Asset = MackySoft.SerializeReferenceExtensions.Tests.DeferredReferenceTestAsset;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DeferredSelectionLifecycleTests
+ {
+ private Asset target;
+ private Asset other;
+ private SerializedObject caller;
+ private DeferredSelectionWindow window;
+ private EditorWindow popup;
+ private string assetPath;
+
+ [UnitySetUp]
+ public IEnumerator SetUp ()
+ {
+ Undo.ClearAll();
+ assetPath = null;
+ target = ScriptableObject.CreateInstance();
+ other = ScriptableObject.CreateInstance();
+ caller = new SerializedObject(target);
+ ShowWindow("reference");
+ yield return null;
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ if (popup != null)
+ {
+ popup.Close();
+ }
+ if (window != null)
+ {
+ window.Close();
+ }
+ caller?.Dispose();
+ if (!string.IsNullOrEmpty(assetPath))
+ {
+ Assert.That(AssetDatabase.DeleteAsset(assetPath), Is.True);
+ }
+ else if (target != null)
+ {
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+ if (other != null)
+ {
+ UnityEngine.Object.DestroyImmediate(other);
+ }
+ Undo.ClearAll();
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void Selection_PreservesEditsStagedBeforeOrAfterOpening (bool afterOpening)
+ {
+ Asset.INode original = target.reference;
+ if (!afterOpening)
+ {
+ StageEdits();
+ }
+ OpenPopup();
+ Assert.That(window.Changed, Is.False);
+ if (afterOpening)
+ {
+ StageEdits();
+ }
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ window.DrawNow();
+
+ AssertBeta(target.reference, 93, 94);
+ Assert.That(target.reference, Is.Not.SameAs(original));
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ Assert.That(window.Changed, Is.True);
+ using (SerializedProperty preserved = caller.FindProperty("reference.amount"))
+ {
+ Assert.That(preserved.intValue, Is.EqualTo(93));
+ }
+ }
+
+ [Test]
+ public void CancellingPopup_PreservesBufferedEditsWithoutReplacingOrNotifying ()
+ {
+ Asset.INode original = target.reference;
+ StageEdits();
+ OpenPopup();
+ popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Escape });
+ caller.ApplyModifiedProperties();
+ window.DrawNow();
+
+ Assert.That(target.reference, Is.SameAs(original));
+ Assert.That(((Asset.Alpha)target.reference).amount, Is.EqualTo(93));
+ Assert.That(((Asset.Alpha)target.reference).payload.value, Is.EqualTo(94));
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ Assert.That(window.Changed, Is.False);
+ }
+
+ [TestCase("elements.Array.data[0]", 11, 12)]
+ [TestCase("inlineItems.Array.data[0].reference", 19, 20)]
+ [TestCase("slots.Array.data[0].reference", 41, 42)]
+ public void UnchangedNullInlineAndValueTypeTargets_AcceptSelection (string path, int amount, int child)
+ {
+ ShowWindow(path);
+ OpenPopup();
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ window.DrawNow();
+ using (SerializedProperty property = caller.FindProperty(path))
+ {
+ AssertBeta((Asset.INode)property.managedReferenceValue, amount, child);
+ }
+ Assert.That(window.Changed, Is.True);
+ Assert.That(target.elements[2], Is.Null);
+ Assert.That(target.unrelated, Is.EqualTo(81));
+ }
+
+ [Test]
+ public void ReorderedElement_SelectionCancelsWithoutEditingOrNotifying ()
+ {
+ ShowWindow("elements.Array.data[1]");
+ Asset.INode first = target.elements[1];
+ Asset.INode second = target.elements[3];
+ OpenPopup();
+ target.elements[1] = second;
+ target.elements[3] = first;
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ caller.Update();
+ window.DrawNow();
+
+ Assert.That(target.elements[1], Is.SameAs(second));
+ Assert.That(target.elements[3], Is.SameAs(first));
+ Assert.That(((Asset.Alpha)first).amount, Is.EqualTo(23));
+ Assert.That(((Asset.Beta)second).amount, Is.EqualTo(47));
+ Assert.That(window.Changed, Is.False);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void DisposedOriginOrDestroyedHost_SelectionSafelyCancels (bool destroyHost)
+ {
+ Asset.INode original = target.reference;
+ OpenPopup();
+ if (destroyHost)
+ {
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+ else
+ {
+ window.Close();
+ caller.Dispose();
+ caller = null;
+ }
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ if (!destroyHost)
+ {
+ Assert.That(target.reference, Is.SameAs(original));
+ }
+ Assert.That(((Asset.Alpha)original).amount, Is.EqualTo(17));
+ Assert.That(other.reference, Is.TypeOf());
+ }
+
+ [Test]
+ public void MultipleHosts_SelectionPreservesEachHostsCurrentValues ()
+ {
+ target.reference = new Asset.Alpha(23);
+ other.reference = new Asset.Beta(47);
+ caller.Dispose();
+ caller = new SerializedObject(new UnityEngine.Object[] { target, other });
+ ShowWindow("reference");
+ using (SerializedProperty unrelated = caller.FindProperty("unrelated"))
+ {
+ unrelated.intValue = 88;
+ }
+ OpenPopup();
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ window.DrawNow();
+
+ AssertBeta(target.reference, 23, 24);
+ AssertBeta(other.reference, 47, 48);
+ Assert.That(target.reference, Is.Not.SameAs(other.reference));
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ Assert.That(other.unrelated, Is.EqualTo(88));
+ Assert.That(window.Changed, Is.True);
+ }
+
+ [Test]
+ public void OneChangedHost_SelectionCancelsForEveryHost ()
+ {
+ Asset.INode original = target.reference;
+ caller.Dispose();
+ caller = new SerializedObject(new UnityEngine.Object[] { target, other });
+ ShowWindow("reference");
+ OpenPopup();
+ var replacement = new Asset.Beta(47);
+ other.reference = replacement;
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ caller.Update();
+ window.DrawNow();
+
+ Assert.That(target.reference, Is.SameAs(original));
+ Assert.That(other.reference, Is.SameAs(replacement));
+ Assert.That(((Asset.Alpha)original).amount, Is.EqualTo(17));
+ Assert.That(window.Changed, Is.False);
+ }
+
+ [Test]
+ public void PopupTypeChange_SupportsUndoRedoAndAssetReloadWithBufferedValues ()
+ {
+ assetPath = "Assets/__DeferredSelection_" + Guid.NewGuid().ToString("N") + ".asset";
+ AssetDatabase.CreateAsset(target, assetPath);
+ AssetDatabase.SaveAssets();
+ Undo.ClearAll();
+ StageEdits();
+ OpenPopup();
+ TypePopupInteraction.SelectVisibleItem(popup, "Deferred Beta");
+ Undo.FlushUndoRecordObjects();
+ AssertBeta(target.reference, 93, 94);
+
+ Undo.PerformUndo();
+ Assert.That(target.reference, Is.TypeOf());
+ Assert.That(((Asset.Alpha)target.reference).amount, Is.EqualTo(93));
+ Assert.That(((Asset.Alpha)target.reference).payload.value, Is.EqualTo(94));
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ Undo.PerformRedo();
+ AssertBeta(target.reference, 93, 94);
+
+ window.Close();
+ caller.Dispose();
+ caller = null;
+ AssetDatabase.SaveAssets();
+ Resources.UnloadAsset(target);
+ target = AssetDatabase.LoadAssetAtPath(assetPath);
+ Assert.That(target, Is.Not.Null);
+ AssertBeta(target.reference, 93, 94);
+ Assert.That(target.unrelated, Is.EqualTo(88));
+ }
+
+ private void ShowWindow (string path)
+ {
+ if (window != null)
+ {
+ window.Close();
+ }
+ window = ScriptableObject.CreateInstance();
+ window.Initialize(caller, path);
+ window.ShowUtility();
+ }
+
+ private void OpenPopup ()
+ {
+ window.Focus();
+ window.DrawNow();
+ window.ClearChanges();
+ Rect rect = window.FieldRect;
+ window.SendEvent(new Event
+ {
+ type = EventType.MouseDown,
+ mousePosition = new Vector2(rect.xMax - 30f, rect.y + EditorGUIUtility.singleLineHeight * 0.5f),
+ button = 0
+ });
+ popup = Resources.FindObjectsOfTypeAll()
+ .SingleOrDefault(candidate => candidate.GetType().Name == "AdvancedDropdownWindow");
+ Assert.That(popup, Is.Not.Null, "Clicking the registered selector opens a real type popup.");
+ window.SendEvent(new Event
+ {
+ type = EventType.MouseUp,
+ mousePosition = new Vector2(rect.xMax - 30f, rect.y + EditorGUIUtility.singleLineHeight * 0.5f),
+ button = 0
+ });
+ }
+
+ private void StageEdits ()
+ {
+ using (SerializedProperty amount = caller.FindProperty("reference.amount"))
+ using (SerializedProperty child = caller.FindProperty("reference.payload.value"))
+ using (SerializedProperty unrelated = caller.FindProperty("unrelated"))
+ {
+ amount.intValue = 93;
+ child.intValue = 94;
+ unrelated.intValue = 88;
+ }
+ }
+
+ private static void AssertBeta (Asset.INode value, int amount, int child)
+ {
+ Assert.That(value, Is.TypeOf());
+ Assert.That(((Asset.Beta)value).amount, Is.EqualTo(amount));
+ Assert.That(((Asset.Beta)value).payload.value, Is.EqualTo(child));
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta
new file mode 100644
index 0000000..57f0e53
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionLifecycleTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 38f1d0721330a440789096d2d63db037
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs
new file mode 100644
index 0000000..e9f4bee
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs
@@ -0,0 +1,62 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class DeferredSelectionWindow : EditorWindow
+ {
+ private SerializedObject serializedTarget;
+ private string propertyPath;
+
+ public Rect FieldRect { get; private set; }
+ public bool Changed { get; private set; }
+
+ public void Initialize (SerializedObject caller, string path)
+ {
+ serializedTarget = caller;
+ propertyPath = path;
+ position = new Rect(100f, 100f, 600f, 160f);
+ }
+
+ public void DrawNow ()
+ {
+ SendEvent(new Event { type = EventType.Layout });
+ SendEvent(new Event { type = EventType.Repaint });
+ }
+
+ public void ClearChanges ()
+ {
+ Changed = false;
+ }
+
+ private void OnGUI ()
+ {
+ if (serializedTarget == null || serializedTarget.targetObject == null)
+ {
+ return;
+ }
+
+ // The caller owns Update/Apply so the selector receives its genuine pending buffer.
+ using (SerializedProperty property = serializedTarget.FindProperty(propertyPath))
+ {
+ if (property == null)
+ {
+ return;
+ }
+ property.isExpanded = false;
+ var label = new GUIContent("Deferred selection");
+ FieldRect = new Rect(16f, 16f, position.width - 32f, EditorGUI.GetPropertyHeight(property, label, true));
+ using (var change = new EditorGUI.ChangeCheckScope())
+ {
+ EditorGUI.PropertyField(FieldRect, property, label, true);
+ Changed |= change.changed;
+ }
+ }
+ }
+
+ private void OnDisable ()
+ {
+ serializedTarget = null;
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta
new file mode 100644
index 0000000..38fe9f3
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/DeferredSelectionWindow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: afbbd2394b74842a096e997834153687
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs
new file mode 100644
index 0000000..12cc07a
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections;
+using UnityEngine;
+
+namespace MackySoft.SerializeReferenceExtensions.Tests
+{
+ public sealed class UnrelatedListReferenceTestAsset : ScriptableObject, IList
+ {
+ [SerializeReference] public DeferredReferenceTestAsset.INode reference = new DeferredReferenceTestAsset.Alpha(17);
+ public Parent parent = new Parent();
+
+ int IList.Add (object value) => ((IList)parent).Add(value);
+ void IList.Clear () => ((IList)parent).Clear();
+ bool IList.Contains (object value) => ((IList)parent).Contains(value);
+ int IList.IndexOf (object value) => ((IList)parent).IndexOf(value);
+ void IList.Insert (int index, object value) => ((IList)parent).Insert(index, value);
+ void IList.Remove (object value) => ((IList)parent).Remove(value);
+ void IList.RemoveAt (int index) => ((IList)parent).RemoveAt(index);
+ bool IList.IsFixedSize => ((IList)parent).IsFixedSize;
+ bool IList.IsReadOnly => ((IList)parent).IsReadOnly;
+ object IList.this[int index] { get => ((IList)parent)[index]; set => ((IList)parent)[index] = value; }
+ int ICollection.Count => ((ICollection)parent).Count;
+ bool ICollection.IsSynchronized => ((ICollection)parent).IsSynchronized;
+ object ICollection.SyncRoot => ((ICollection)parent).SyncRoot;
+ void ICollection.CopyTo (Array array, int index) => ((ICollection)parent).CopyTo(array, index);
+ IEnumerator IEnumerable.GetEnumerator () => ((IEnumerable)parent).GetEnumerator();
+
+ [Serializable]
+ public sealed class Parent : IList
+ {
+ [SerializeReference] public DeferredReferenceTestAsset.INode reference = new DeferredReferenceTestAsset.Alpha(23);
+
+ int IList.Add (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ void IList.Clear () => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ bool IList.Contains (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ int IList.IndexOf (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ void IList.Insert (int index, object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ void IList.Remove (object value) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ void IList.RemoveAt (int index) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ bool IList.IsFixedSize => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ bool IList.IsReadOnly => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ object IList.this[int index]
+ {
+ get => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ set => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ }
+ int ICollection.Count => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ bool ICollection.IsSynchronized => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ object ICollection.SyncRoot => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ void ICollection.CopyTo (Array array, int index) => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ IEnumerator IEnumerable.GetEnumerator () => throw new InvalidOperationException("Unrelated list API is unavailable during Inspector edits.");
+ }
+ }
+}
diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta
new file mode 100644
index 0000000..a568e40
--- /dev/null
+++ b/Assets/Tests/SerializeReferenceExtensions/Editor/UnrelatedListReferenceTestAsset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c4ed90e4f7bf14b6794d643c013a2c8a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: