Context
Found through the Fleet Manager's first-run path (institution #74 → #76): after the roster list renders EMPTY once, the next row that arrives renders as an empty li — the pooled AgentCard instance is re-seated by reference, and the reference resolves to a node that left the DOM. Measured live on 2026-09-02 through the Neural Link (session-scoped, the Browser-pane cockpit): 11 pooled cards mounted: true → fleetRoster.clear() → DOM has 0 li, title Fleet · 0 agents, and all 11 cards STILL mounted: true → add([2 rows]) → two li with zero children, title Fleet · 2 agents, zero .fm-agent-card; cards 0/1 re-seated, cards 2–10 flipped to mounted: false only now (the refill's own sync ran the unmount pass; the empty pass never did). So a settled-empty roster followed by ANY refill blanks the whole fleet grid — not a first-run-only defect.
Pinned in isolation with an Engine unit spec (three arms, run against dev@46c441eaba): a pool container updating ALONE unmounts its removed pooled children (green); the same removal merged into an ancestor flight with updateDepth: -1 leaves them mounted: true with stale vnodes (red); the consequence arm re-seats one of them by reference and the update produces ZERO deltas (red) — the placeholder points at a node that no longer exists.
The Problem
A component that is removed from the tree inside a parent's delta must be told, and so must its descendants. syncVnodeTree tells the direct children of the component that received the vnode — one level. When a child's update is merged into an ancestor's flight and the ancestor's depth covers it, the child never receives a vnode of its own, its syncVnodeTree never runs, and its removed children keep mounted: true and their old vnodes. The tree builder then treats every later reference to them as a placeholder ("move my existing node here") for a node the DOM no longer has.
Any pooled-instance renderer (list.Component, the calendar component lists, the fleet roster) that clears and refills while an ancestor is updating in the same tick hits this. The observable is silent: no error, an empty node.
The Architectural Reality
src/mixin/VdomLifecycle.mjs syncVnodeTree() (~L905): pass 1 iterates ComponentManager.getChildren(me) — the components whose nodes are present in the NEW vnode (VNodeUtil.getChildIds skips componentId placeholders) — and sets component._vnode SILENTLY (~L946), so afterSetVnode never fires for them and their own syncVnodeTree never runs. Pass 2 (~L955–973, the #8868 fix 286640b2d4) unmounts only ComponentManager.getDirectChildren(me.id) that are neither in the new tree nor found in vnodeMap — one level.
syncVnodeTree has exactly one caller: afterSetVnode (~L125). A component gets a vnode only when response.vnodes[id] carries one (~L349–363).
mergeIntoParentUpdate (~L538 region) merges a child into ANY ancestor whose needsVdomUpdate is set — canMergeUpdate returns true unconditionally — and the collision filter in executeVdomUpdate (~L289–309) deletes the child's disjoint payload when an ancestor's flight covers it (parentDepth === -1 || parentDepth > distance). Covered child ⇒ no own vnode ⇒ no own sync ⇒ its removed children are never unmounted.
src/util/vdom/TreeBuilder.mjs #buildTree (~L59–84): a referenced component WITH a vnode is sent as a placeholder ({componentId, neoIgnore: true} when pruned, or a reference the worker moves); only a component WITHOUT a vnode is inlined from its vdom. A stale vnode therefore turns a re-seat into a move of a missing node.
- The pruned-branch case is safe for the fix: the worker keeps a pruned child's previous subtree in the returned vnode (ids present), so
vnodeMap contains kept children and lacks only removed ones — the distinction pass 2 already relies on.
The Fix
src/mixin/VdomLifecycle.mjs, syncVnodeTree():
- Extract pass 2 into a module-level helper
unmountRemovedChildren(parent, vnodeMap, presentSet): for each ComponentManager.getDirectChildren(parent.id) that is not in the present set, not found in vnodeMap by its vdom.id, and not floating → unmount.
- Run that helper for
me (today's pass 2) AND for every component pass 1 synced — the components whose subtree the new vnode carries in full. One childMap lookup per synced component; no vnode crawl, no change to pass 1's membership (the 2026-03 getChildComponents swap that broke the grid teleportation specs stays rejected).
- Unmounting recurses over the logical subtree of the removed component (
getDirectChildren recursively, skipping floating): _vnode = null, mounted = false — a removed subtree's descendants cannot stay mounted either, or the re-inlined parent references children with stale vnodes one level down (the institution's third fix shape lost the card's avatar Image exactly this way).
- The reproducer lands beside
test/playwright/unit/vdom/HiddenChildren.spec.mjs (#8868's, the one-level precedent) as PooledChildUnmount.spec.mjs — control, merged, consequence.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
VdomLifecycle#syncVnodeTree invariant |
src/mixin/VdomLifecycle.mjs (#8868 established the one-level form) |
after a sync, every component whose node left the synced subtree — at any depth below a synced component — has mounted === false and vnode === null, recursively |
none: a stale vnode is the defect |
the method's JSDoc (two-pass strategy paragraph) |
test/playwright/unit/vdom/PooledChildUnmount.spec.mjs red-first, green after |
Decision Record impact: none.
Acceptance Criteria
Out of Scope
- Any app-side workaround in the fleet roster (
list.Component pooling stays as is; #76 consumes this leaf).
- The
list.plugin.Animate fade timing and the store clear → list event path.
- Changing when children merge (
canMergeUpdate) or the collision filter — the unmount pass is the symmetric counterpart of the mount pass and belongs in the sync.
Avoided Traps
- Replacing pass 1's
getChildren (nodes in the new tree) with a recursive logical walk — tried 2026-03-08, broke the grid teleportation specs and costs a crawl per sync; the fix touches the UNMOUNT side only.
- Destroying or resetting pooled instances app-side: three shapes tried in the institution (#76 body) — duplicated rows while old ones fade, or lost child components after the re-inline.
- Treating an absent
vnodeMap entry as removal for a PRUNED branch: the worker keeps pruned subtrees in the returned vnode, so the map distinguishes kept from removed — the existing pass 2 contract; the extension inherits it unchanged.
Related
- #8868 /
286640b2d4 — the one-level unmount pass this generalizes
- neomjs/neo-agent-institution#76 (consumer; the live observation and the three failed app-side shapes), #74 (the zone that exposed it)
test/playwright/unit/vdom/AsymmetricMerging.spec.mjs — the merged-flight harness the reproducer reuses
Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T01:14Z and the top 8 again at 01:16Z (no equivalent; title search for unmount / syncVnodeTree / placeholder returned nothing); A2A claims over the last 30 messages cover native drops, the toolbar action and the dock lock — none on the vdom lifecycle; Memory Core raw sweep surfaced only #8868's fix session and the 2026-03 getChildComponents attempt.
Origin Session ID: 41d54300-40e3-4364-b351-7a6d2d828e1c
Retrieval Hint: syncVnodeTree merged child covering ancestor flight grandchildren stay mounted stale vnode placeholder empty li pooled list.Component
📜 Clio
Context
Found through the Fleet Manager's first-run path (institution #74 → #76): after the roster list renders EMPTY once, the next row that arrives renders as an empty
li— the pooledAgentCardinstance is re-seated by reference, and the reference resolves to a node that left the DOM. Measured live on 2026-09-02 through the Neural Link (session-scoped, the Browser-pane cockpit): 11 pooled cardsmounted: true→fleetRoster.clear()→ DOM has 0li, titleFleet · 0 agents, and all 11 cards STILLmounted: true→add([2 rows])→ twoliwith zero children, titleFleet · 2 agents, zero.fm-agent-card; cards 0/1 re-seated, cards 2–10 flipped tomounted: falseonly now (the refill's own sync ran the unmount pass; the empty pass never did). So a settled-empty roster followed by ANY refill blanks the whole fleet grid — not a first-run-only defect.Pinned in isolation with an Engine unit spec (three arms, run against
dev@46c441eaba): a pool container updating ALONE unmounts its removed pooled children (green); the same removal merged into an ancestor flight withupdateDepth: -1leaves themmounted: truewith stale vnodes (red); the consequence arm re-seats one of them by reference and the update produces ZERO deltas (red) — the placeholder points at a node that no longer exists.The Problem
A component that is removed from the tree inside a parent's delta must be told, and so must its descendants.
syncVnodeTreetells the direct children of the component that received the vnode — one level. When a child's update is merged into an ancestor's flight and the ancestor's depth covers it, the child never receives a vnode of its own, itssyncVnodeTreenever runs, and its removed children keepmounted: trueand their old vnodes. The tree builder then treats every later reference to them as a placeholder ("move my existing node here") for a node the DOM no longer has.Any pooled-instance renderer (
list.Component, the calendar component lists, the fleet roster) that clears and refills while an ancestor is updating in the same tick hits this. The observable is silent: no error, an empty node.The Architectural Reality
src/mixin/VdomLifecycle.mjssyncVnodeTree()(~L905): pass 1 iteratesComponentManager.getChildren(me)— the components whose nodes are present in the NEW vnode (VNodeUtil.getChildIdsskipscomponentIdplaceholders) — and setscomponent._vnodeSILENTLY (~L946), soafterSetVnodenever fires for them and their ownsyncVnodeTreenever runs. Pass 2 (~L955–973, the #8868 fix286640b2d4) unmounts onlyComponentManager.getDirectChildren(me.id)that are neither in the new tree nor found invnodeMap— one level.syncVnodeTreehas exactly one caller:afterSetVnode(~L125). A component gets a vnode only whenresponse.vnodes[id]carries one (~L349–363).mergeIntoParentUpdate(~L538 region) merges a child into ANY ancestor whoseneedsVdomUpdateis set —canMergeUpdatereturnstrueunconditionally — and the collision filter inexecuteVdomUpdate(~L289–309) deletes the child's disjoint payload when an ancestor's flight covers it (parentDepth === -1 || parentDepth > distance). Covered child ⇒ no own vnode ⇒ no own sync ⇒ its removed children are never unmounted.src/util/vdom/TreeBuilder.mjs#buildTree(~L59–84): a referenced component WITH a vnode is sent as a placeholder ({componentId, neoIgnore: true}when pruned, or a reference the worker moves); only a component WITHOUT a vnode is inlined from itsvdom. A stale vnode therefore turns a re-seat into a move of a missing node.vnodeMapcontains kept children and lacks only removed ones — the distinction pass 2 already relies on.The Fix
src/mixin/VdomLifecycle.mjs,syncVnodeTree():unmountRemovedChildren(parent, vnodeMap, presentSet): for eachComponentManager.getDirectChildren(parent.id)that is not in the present set, not found invnodeMapby itsvdom.id, and notfloating→ unmount.me(today's pass 2) AND for every component pass 1 synced — the components whose subtree the new vnode carries in full. OnechildMaplookup per synced component; no vnode crawl, no change to pass 1's membership (the 2026-03getChildComponentsswap that broke the grid teleportation specs stays rejected).getDirectChildrenrecursively, skippingfloating):_vnode = null,mounted = false— a removed subtree's descendants cannot stay mounted either, or the re-inlined parent references children with stale vnodes one level down (the institution's third fix shape lost the card's avatarImageexactly this way).test/playwright/unit/vdom/HiddenChildren.spec.mjs(#8868's, the one-level precedent) asPooledChildUnmount.spec.mjs— control, merged, consequence.Contract Ledger Matrix
VdomLifecycle#syncVnodeTreeinvariantsrc/mixin/VdomLifecycle.mjs(#8868 established the one-level form)mounted === falseandvnode === null, recursivelytest/playwright/unit/vdom/PooledChildUnmount.spec.mjsred-first, green afterDecision Record impact: none.
Acceptance Criteria
test/playwright/unit/vdom/PooledChildUnmount.spec.mjsexists with the three arms (control, merged into a covering ancestor flight, re-seat consequence) and is green; the merged and consequence arms are shown red at the pre-fix head in the PR.mounted: false/vnode: null.test/playwright/unit/vdom/**andtest/playwright/unit/grid/Teleportation.spec.mjsstay green; the fullunitproject at the PR head is green.syncVnodeTree's JSDoc names the extended pass and its guard (placeholder-kept vs removed).AddAgentJourneyNLrenders the graduated card and a settled-empty roster refill renders every card.Out of Scope
list.Componentpooling stays as is; #76 consumes this leaf).list.plugin.Animatefade timing and the storeclear→ list event path.canMergeUpdate) or the collision filter — the unmount pass is the symmetric counterpart of the mount pass and belongs in the sync.Avoided Traps
getChildren(nodes in the new tree) with a recursive logical walk — tried 2026-03-08, broke the grid teleportation specs and costs a crawl per sync; the fix touches the UNMOUNT side only.vnodeMapentry as removal for a PRUNED branch: the worker keeps pruned subtrees in the returned vnode, so the map distinguishes kept from removed — the existing pass 2 contract; the extension inherits it unchanged.Related
286640b2d4— the one-level unmount pass this generalizestest/playwright/unit/vdom/AsymmetricMerging.spec.mjs— the merged-flight harness the reproducer reusesLive latest-open sweep: checked the latest 20 open issues at 2026-09-02T01:14Z and the top 8 again at 01:16Z (no equivalent; title search for unmount / syncVnodeTree / placeholder returned nothing); A2A claims over the last 30 messages cover native drops, the toolbar action and the dock lock — none on the vdom lifecycle; Memory Core raw sweep surfaced only #8868's fix session and the 2026-03
getChildComponentsattempt.Origin Session ID: 41d54300-40e3-4364-b351-7a6d2d828e1c
Retrieval Hint:
syncVnodeTree merged child covering ancestor flight grandchildren stay mounted stale vnode placeholder empty li pooled list.Component📜 Clio