Skip to content

Comparison

The cross-subject synthesis over the whole corpus — the Tier-1 five and the Tier-2 four: two scannable matrices, what the field actually disagrees about, the architectural families the disagreements cluster into, the forks a Sparkles design will have to take, what Tier 2 retracted from the Tier-1 reading, and the things that were not obvious before reading the source.

Last reviewed: August 19, 2026

NOTE

Eight subjects were read from source at pinned revisions (Qt, Godot, WinForms, bevy, Unity, the derive-macro crates, VS Code, rjsf, DevTools). Unreal was read from archived API documentation only — its source requires an authenticated account — and every Unreal claim inherits that weaker status. Statements that are analysis rather than observation are marked INFERENCE.


Matrix I — architecture

Subjects as rows. Tier-2 subjects are marked ★.

SubjectWhere the tree livesNode addressSurvives a rebuild byMetadata sourceDescent decision
Qt Property Browserindependent data modelQtProperty*there being no rebuildnone — caller builds the treehasValue() on the manager
Godot EditorInspectorthe widget tree itself(object, "path/string")restoring selection/focus by hand; fold state on the objectflat PropertyInfo streamVariant type + hint
WinForms PropertyGridretained entry modelGridEntryre-matching children structurallyTypeDescriptor attributesTypeConverter.GetPropertiesSupported
Unreal Details (docs)node tree behind handlesIPropertyHandlethe handle, with IsValidHandle()UPROPERTY + meta= maphandle has children
bevy-inspector-eguinowhere — a stack framehashed structural path (egui::Id)recomputing the same idbevy_reflect + type registryReflectMut discriminant
Unity SerializedPropertyserialized mirror + cursormutable cursor plus propertyPath stringisExpanded living in the serialized dataserialization system + attributesthe cursor's enterChildren
Derive-macro cratesthe call graph of generated implscall-site idbeing the same call graph next frameattributes on the typeassociated const / overridden visitor
VS Code settingssettings tree modelsanitizeId(parent + key)the model outliving the viewJSON-schema contributionsnone — two levels of grouping, flat rows
react-jsonschema-formnowhere — fields recurse per renderfield path plus synthetic row keysReact reconciliation over those keysa JSON Schema document + uiSchemathe schema's type
DevTools object inspectorthe other processremote objectId (a lease) + path()refetching on expansionCDP property descriptorsevery value with children
sparkles:ui todayper-frame tree + explicit state valuesadapter-minted KeyTreeViewState(Key)— (UDAs unused for this)

Matrix II — behaviour

SubjectMaterialisationCyclesMutationTransient vs committedCollectionsSum typesMulti-objectFilterRow cost
Qteager, whole subtreeforbidden at inserteditor → manager setter, livenonenoneenum/flags onlynonenoneitem + heap property per node
Godotlazy — sub-inspector on unfoldno guardundo transaction (MERGE_ENDS)changing suppresses the rebuildpaged rows, add/remove/reorderresource picker; subtree replacedintersection, first object's valuerebuild; folding disabledone Control per row
WinFormslazy on expandno guarddesigner transactioncommit on Enter/blur; modal error keeps textarray index rows; modal editornonemerged descriptors, blank when mixednonezero widgets (one shared editor)
Unreal (docs)not determinednot determinedthrough the handle (transaction included)SetValue flags + NotifyFinishedChangingPropertiesAsArray/AsMap/AsSetGeneratePossibleValues + restrictionsper-object values, first-classnot determinednot determined
bevyper frame, only inside open regionsimpossible (&mut aliasing)&mut in placenoneinline add/remove/movevariant combo; unconstructable entries disabledui_for_reflect_manynonezero retained; re-walked per frame
Unitytraversal skips collapsed subtreesvisited set — in expand-all onlymirror + ApplyModifiedProperties (undo)none at the C# layerreorderable list, keyed by path[SerializeReference]; subtree rebuilt on type changehasMultipleDifferentValues (+ per-bit), showMixedValuetable views onlyIMGUI, per-visible-row
derive cratesper frame, open regionsimpossible (&mut)&mut in placenonepositional rows, add/remove/movederive generates construction — non-Default field is a build errornonenonezero retained
VS Codemodel built once, tree virtualizesn/a (flat key space)configuration service writes settings.jsonper controlinline widgets for arrays/maps; else Complexnone — Complexn/a (scope selector instead)model swap, expansion untouchedvirtualized, recycled templates
rjsfper rendertagged, rendered as Expandimmutable onChange; host owns dataper keystroke into formDataadd/remove/reorder, stable row keysoneOf picker; data survives via name+type matchnonenonewhole DOM
DevToolsper expansion — a network fetchnone needed (no automatic walk)remote evaluationnonerecursive [from … to] buckets; 200-row limitread-onlynoneover materialised rows onlyfetched rows only
sparkles:uiflatten descends into open nodes; viewSlice windowscompile-time walk fails to build without a visited-type settable core, read-onlynonefilter owned by TreeViewStatezero retained

Per-dimension synthesis

1. Where the tree lives, and what it costs

Four approaches now, not three.

A model independent of any view (Qt, Unreal, ★VS Code). Buys presentation-independence: Qt drives three browsers from one model; VS Code renders its model through a virtualized tree and swaps the whole model for search. The cost is a second structure to maintain — Qt materialises an item per occurrence and fans changes out over the list.

The widget tree as the only tree (Godot). Buys directness, costs a teardown per structural change, and forces the state that must survive onto the edited object (editor_inspector.cpp:4396 admits the caret is lost).

No tree at all (bevy, ★derive crates, ★rjsf). Buys the elimination of rebuild, invalidation and change notification. Costs positional identity — except in rjsf, which pays one synthetic key per array row to get identity back (ArrayField.tsx:44).

A mirror of the subject (★Unity). The inspector edits a serialized copy, pulled by Update() and pushed by ApplyModifiedProperties() — which is also where undo is registered (SerializedObject.bindings.cs:122). This is the only subject that decouples editing from the object without building a node model: the mirror is the model.

DevTools is the limiting case of all four: the tree is in another process, so the inspector holds leases and refetches.

2. Metadata

Five channels: none (Qt), runtime attribute tables (WinForms, ★Unity), a runtime stream (Godot), a type registry (bevy), a document (★VS Code, ★rjsf) — and, new in Tier 2, attributes consumed at compile time (★derive crates).

The compile-time channel changes the failure mode rather than the expressiveness: a misspelled attribute is a build error, and a per-field custom renderer is a function name resolved at compile time. What it cannot express is a condition over the value — which the document-driven subjects get for free (if/then/else, dependencies in JSON Schema) and which uda-metadata.d shows a D UDA channel must carry as data and evaluate per frame.

Unity adds one distinction nobody else has at the API level: metadata is readable at class scope and instance scope (GetMetaData vs GetInstanceMetaData in Unreal; SerializedProperty + drawer attributes in Unity), so per-occurrence metadata is possible.

VS Code adds provenance: a row carries scopeValue, defaultValue, defaultValueSource, isConfigured, hasPolicyValue, per-language overrides (settingsTreeModels.ts:113). No other subject models why a value is what it is.

3. Descent, and who owns it

Unchanged in shape — model type (WinForms), value kind (bevy), manager (Qt), runtime type+hint (Godot) — with two Tier-2 additions.

Derive crates put it on the trait, at compile time: an associated const (SIMPLE) or simply whether the impl overrides the child visitor. That is the same capability-by-presence idiom sparkles:ui already uses for inspector adapters.

VS Code declines the question: nested values are typed Complex and handed to the text editor ("Edit in settings.json", settingsTree.ts:1203). A GUI that refuses to descend, and offers the serialization format instead, is a legitimate answer nobody in Tier 1 considered.

4. Cycles — the rule the corpus was missing

Tier 1 concluded that nobody solves cycles; they arrange not to have them. Tier 2 supplies the rule two independent subjects state in opposite directions:

★ Managed reference objects can form a cyclical graph, so need to track visited objects

Unity, inside SetExpandedRecurse (EditorGUI.cs:7841)

★ Should only be true when called from an object-property context, because object properties are always rendered (creating an infinite loop), whereas array items and anyOf/oneOf branches are data-driven.

rjsf on when to tag a $ref cycle (retrieveSchema.ts:366)

Guard the walk that is neither user-driven nor data-driven. Unity's visited set is in expand-all, not in painting. rjsf's cycle tag is for object properties, which are always rendered, and not for array items or oneOf branches, which the data terminates. DevTools needs no guard because it has no automatic walk at all. Qt forbids cycles at the model boundary; bevy and the ★derive crates get freedom from &mut aliasing; Godot and WinForms rely on the reader stopping.

rjsf also contributes the best presentation of a cut: CyclicSchemaField renders a placeholder with an Expand button that opens exactly one more level (CyclicSchemaField.tsx:25) — the cut is visible and actionable rather than silent. erased-descent.d reproduces that shape in D.

For Sparkles the constraint is sharper than for anyone surveyed: a CTFE walk fails to build on a recursive type (reflect-descent.d), and the escape is to put an erasure boundary in the child walk — which is precisely what the ★derive crates do with &mut dyn, and what erased-descent.d measures the cost of (one delegate per open node, one virtual call per descent).

5. Editing, commitment and undo

Undo is present in exactly the subjects embedded in a host that already had an undo stack (Godot, WinForms, Unreal, ★Unity — where ApplyModifiedProperties() is the undo boundary). Every standalone library has none: Qt, bevy, all four ★derive crates, ★rjsf (which hands formData to the host), ★DevTools. Nine subjects, no counterexample: undo belongs to the host.

The transient/committed distinction remains rare and remains misread. Godot'schanging flag suppresses the rebuild, not the write (editor_inspector.cpp:5829). Only Unreal separates the two at the API. ★Unity has no equivalent at the C# layer — its merge happens in the native undo system.

Validation gained a second data point: WinForms blocks navigation with a modal and keeps the reader's text; ★VS Code renders the message inline in the row (settingsTree.ts:1215); ★rjsf makes validation a designed stage whose errorSchema is addressed by the same path as the data.

6. Polymorphic and sum-typed values

The corpus now shows the full spread of "what happens on a variant switch":

ApproachSubjectOn switch
Runtime constructability check, unconstructable variants disabled with reasonsbevyold data discarded
Derive generates the constructionderive cratesold data discarded; a non-Default field is a build error
Concrete type name is part of the valueUnitysubtree torn down when managedReferenceFullTypename changes (PropertyField.cs:249)
Picker over allowed classesGodotvalue replaced; subtree is whatever the new object reports
Metadata-driven options with restrictionsUnrealnot determined
Schema oneOf with data migrationrjsfdata survives: keys whose name and resolved type match are carried over (sanitizeDataForNewSchema.ts:117)

The last row is a Tier-1 retraction (see Retractions). For D specifically the obstacle is neither construction nor migration: it is @safe (sumtype-variants.d).

7. Multi-object editing

Four positions, and the Tier-2 addition is the cheapest one for the view:

  • Designed in (Unreal): the handle addresses N objects; every read returns a result code.
  • Merged descriptors (WinForms): allEqual out-parameter; a mixed row renders blank.
  • ★ Ambient flag (Unity): hasMultipleDifferentValues per property — including a per-bit variant for masks — and a global EditorGUI.showMixedValue that every control consults, drawing an em-dash (EditorGUI.cs:273). Cheapest for the view, most demanding of the model, which must maintain an invalidatable comparison cache.
  • Incomplete (Godot): intersect the property lists, then show the first object's value.

VS Code shows the adjacent problem — one row, several underlying values — solved by a scope selector instead of a merge: pick which one you are editing.

8. Presentation, filtering, performance

Filtering has three answers now. Godot rebuilds and disables folding while a filter is active; sparkles:ui's tree asks its adapter to rebuild; ★VS Code swaps in a different model (settingsEditor2.ts:453), so filtering and expansion never interact at all. The last is the cleanest and costs a second model to keep behaviourally consistent.

On scale the corpus splits by what a row costs. WinForms and ★VS Code virtualize (zero widgets per unrendered row). Godot pays a Control per row and answers size with pagination. ★DevTools answers it with fetch policy — 200 visible children, then a "show all"; arrays above 100 elements become recursive [from … to] buckets (ObjectPropertiesSection.ts:2528) — which is cheaper than virtualization because unfetched rows do not exist. sparkles:ui already windows rows with viewSlice.


Cross-cutting: the frame model

FeatureSurvives per-frame rebuild?Evidence
Expansionyes, if identity is derivable or stored outside the viewGodot on the object; ★Unity in the serialized data; bevy from the id; sparkles:ui's DisclosureState(Key)
Selection / focusyes, with explicit save-restoreGodot around _clear()
In-progress textno in practiceGodot loses the caret; bevy keeps it only via a stable id
Element state in a collectiononly with a synthetic keyrjsf alone; everyone else keys by index
Drag-transient editsyes, if the write is per-frame anywaybevy; Unreal flags them instead
Virtualizationyes — orthogonalWinForms, ★VS Code, sparkles:ui
Undo groupingno — needs "the same interaction"Godot's MERGE_ENDS, Unreal's set flags

INFERENCE: the features that genuinely force retention are those tied to an interaction that spans frames — in-progress text and undo grouping — plus collection element state, which needs a key that is not the index.

Cross-cutting: surface independence

Tier 2 adds two data points to the same reasoning:

  • A GUI may legitimately refuse.VS Code's Complex → "edit the JSON" says a property editor need not be total over its value space, provided the escape lands somewhere real. For the script-free HTML target that is the whole design: render what the row model can express, and let the underlying format be edited elsewhere.
  • Fetch policy is a portable substitute for virtualization.DevTools' limits are policy, not layout — they work identically in a cell grid, and they bound work before it reaches the renderer.

Unchanged: pointer/hover affordances (Godot's hover-only row icons), sub-cell geometry (WinForms' draggable splitter), modal escapes (WinForms' collection editor), and a live runtime (bevy, ★derive crates) all assume things a terminal or a static page does not have.


Architectural families

Three families in Tier 1; Tier 2 adds a fourth and populates the others.

Family A — Model-first (Qt, Unreal, ★VS Code)

A presentation-free model outlives any view; the view subscribes. Commits you to: a second structure and its lifetime. Buys you: several presentations, model-level search (VS Code swaps models), multi-object addressing as an indirection you already have.

Family B — View-first with external state (Godot, WinForms, ★Unity)

The presented rows are the structure; what must survive lives elsewhere — on the object (Godot), in the serialized data (★Unity), or reconstructed by matching (WinForms). Commits you to: naming every piece of surviving state, and losing what you fail to name. Buys you: directness and no synchronisation problem.

Family C — Function-first (bevy, ★derive crates, ★rjsf)

The tree is a recursive function; persistence is a side table keyed by a derived id. Commits you to: positional identity (unless you mint keys, as rjsf does) and a frame clock. Buys you: no rebuild, no invalidation, no notification.

★ Family D — Handle-first over a foreign graph (★DevTools)

The value is not yours: you hold leases, fetch on expansion, and bound by policy. Commits you to: asynchrony everywhere, staleness, and never knowing the row count. Buys you: the ability to inspect something unbounded, live and hostile — and a clean split between displaying and evaluating (getters stay uninvoked).

sparkles:ui remains Family C's frame model with Family B's state discipline: rebuilt per frame, but with no ambient id-keyed memory, so every persistent thing is a named value the host owns (baseline).


Decisions we will have to make

Eight forks, re-run against the Tier-2 evidence. Options and what each forecloses; no recommendation.

D1. Is there a node model at all, or is the tree a function of T?

  • A row model built per rebuild — inspectable, testable, paintable read-only on the HTML target.
  • A pure function walked per frame — smallest, matches the frame model; forecloses asking "how many rows does T have?" without painting.

Tier-2 evidence:VS Code shows a model buys search as a model swap, which is the cleanest filter/expansion story in the corpus. ★DevTools shows that when the row count is unknowable, a model is impossible — irrelevant for a typed subject, decisive for a live foreign one.

D2. Where does the descent decision live?

  • On the type, at compile time — total, checkable, no registry; forecloses per-instance decisions.
  • On an adapter the host supplies — per-use flexibility; forecloses "any T just works".

Tier-2 evidence: the ★derive crates prove the compile-time option works at scale (associated const / overridden visitor), and prove its cost: the orphan rule. A type you do not own cannot be given a rendering without a wrapper. A registry (WinForms) has no such limit.

D3. What is a node's address?

  • A dotted path string — readable, persistable, stable; allocates.
  • A compile-time row index — free and exact; breaks on variant switches and collection edits.
  • An adapter-minted Key — consistent with TreeViewState(Key); defers the question.

Tier-2 evidence:Unity runs cursor + path string and keeps per-row side tables keyed by the path — the pattern works, at the cost of stringly-typed lookups. ★rjsf demonstrates the one case an index cannot serve: collection element state needs a synthetic key, or deleting element 0 shifts every later row's state.

D4. How does a leaf editor get chosen?

  • Compile-time dispatch (static if ladder + per-field UDA overrides) — no registry, @nogc-compatible.
  • A runtime registry — open extension; would be sparkles:ui's first dynamic dispatch surface.

Tier-2 evidence: the ★derive crates show compile-time dispatch is viable and that its escape hatch is cheap — a per-field function name (custom_func_mut, as angle) resolved at compile time. ★VS Code shows the opposite pole working too: a closed renderer set where extensions contribute data, not presentation.

D5. What is the mutation contract?

  • Write through a reference, immediately — simplest; forecloses undo, transient edits and multi-object editing, and inherits the @system SumType assignment problem.
  • Emit an edit command the host applies — undo and multi-object become the host's; costs a command vocabulary and a way to name the target field.

Tier-2 evidence:Unity's mirror is a third option — edit a copy, commit at a boundary (ApplyModifiedProperties) — and that boundary is exactly where undo attaches. ★rjsf is a fourth: the component owns nothing and the host holds the data. Across nine subjects, every standalone library delegates undo to a host; none invents its own.

D6. Does the component support editing at all in v1?

  • Read-only inspection first — deliverable now, serves the inspector spec's details pane and the HTML target, does not block on the unstarted editor component.
  • Editable from the start — blocks on that component for every string field.

Tier-2 evidence:DevTools is a fully useful, widely used property tree that is almost entirely read-only, and its most interesting decisions (fetch bounds, buckets, uninvoked getters) are all on the read path.

D7. Which "unset" does D's Optional row mean?

Nullable!T, T*, Expected!(T, E), a SumType with a unit variant.

Tier-2 evidence: this is the axis Tier 2 changed most. ★rjsf has explicit present-vs-absent controls (OptionalDataControlsField.tsx:46); ★VS Code distinguishes not-set-here-but-inherited from set-here (isConfigured) and shows the provenance of the inherited value. Both are user-facing states, not type distinctions — so the fork is really "how many kinds of unset does the row model name?", independently of how D spells them.

D8. Multi-subject editing: now, later, or never?

Tier-2 evidence:Unity shows the cheapest known implementation — one ambient showMixedValue flag plus a per-property "do they differ?" predicate — but it is cheap only because the model maintains the comparison. Deciding "never" is still a decision about D3.


Retractions: what Tier 2 changed

Claims from the Tier-1 pass that did not survive, and their narrowed replacements.

Tier-1 claimStatusReplacement
"Nobody attempts to carry per-field state across a variant switch."Retractedrjsf migrates data on oneOf change, keeping keys whose name and resolved type match (sanitizeDataForNewSchema.ts:117). The type-driven subjects still discard.
"The only cycle check in the entire corpus is Qt's."RetractedUnity has a runtime visited set in SetExpandedRecurse, and ★rjsf tags $ref cycles during schema resolution. Qt's remains the only insert-time structural one.
"Nobody in the corpus uses a visited set over values."RetractedUnity keys one by managedReferenceId.
"Element identity is positional everywhere."Retractedrjsf mints a synthetic row key per element and keeps it beside the data.
"Recursion is user-driven, so no guard is needed."NarrowedTrue only for walks the user or the data drives. An automatic walk (expand-all, schema resolution) needs a guard — stated independently by ★Unity and ★rjsf.
"Validation is a modal, or absent."NarrowedVS Code renders it inline per row; ★rjsf makes it a stage with path-addressed errors.
"Undo belongs to a host."Upheld, strengthenedNine subjects, no counterexample.
"No subject preserves in-progress text across a structural rebuild."UpheldUnity avoids the question the same way as the rest: nothing is rebuilt while typing.

Surprises

  1. Qt's model is a DAG, and the browser knows ititems(property) returns a list of occurrences.
  2. Nesting in WinForms is not implemented by the grid — the converter decides and supplies children; without [NotifyParentProperty] a nested edit silently does nothing.
  3. Godot's changing flag does not defer the commit — it defers the rebuild.
  4. Godot's fold state lives on the edited object, so it is serialized with the scene.
  5. Godot's multi-selection shows the first node's value with no mixed marker.
  6. Godot's answer to big collections is pagination, not virtualization.
  7. Qt is the only subject that forbids cycles structurally, at insert time.
  8. bevy's cycle-freedom is a borrow-checker artifact, and the price is that cross-object references leave the walk entirely.
  9. bevy disables variant entries it cannot construct, and says which field types blocked it.
  10. A compile-time descent in D turns the cycle problem into a build failure — verified on both compilers.
  11. In D the variant-switch obstacle is @safe, not constructability.
  12. Not one subject preserves in-progress text across a structural rebuild.
  13. Unity's expansion state lives in the serialized data, not in the window — one step beyond Godot: it survives selection changes and domain reloads because it is part of the object's serialized form.
  14. Unity caps custom-drawer recursion with a nesting-indexed drawer list — a drawer cannot draw its own type forever; past the list's end the default field takes over.
  15. The corpus's two visited sets are both in walks nobody clicked — expand-all and schema resolution. That, not "reflective editors need visited sets", is the rule.
  16. rjsf renders a cycle as an Expand button, making an infinite structure finite and legible instead of silently cut.
  17. rjsf migrates data across a variant switch by name-and-type matching — the only subject that tries, and it is a heuristic that can carry a same-named field into a different meaning.
  18. A settings row is not (label, value) — VS Code's carries scope, default, default source, policy lock and per-language overrides, i.e. why the value is what it is.
  19. VS Code answers search by swapping the model, so filtering never touches expansion.
  20. DevTools refuses to invoke getters — the only subject that treats displaying and evaluating as different acts.
  21. DevTools bounds by fetch policy, not rendering policy (200 children, 100-element buckets, recursive ranges), which is cheaper than virtualization because unfetched rows do not exist.
  22. The derive family's recursion terminates because of an erasure boundary, not because of Rust — the same boundary in D turns our build error into a runtime walk, at the price of a delegate per open node (erased-descent.d).

Sources

Per-subject sources are in each deep-dive: Qt, Godot, WinForms, Unreal, bevy, ★Unity, ★derive crates, ★VS Code, ★rjsf, ★DevTools, Sparkles baseline. Revisions are recorded in the revision ledger.