LearnNewsExamplesServices
Frontmatter
id18060
titleGrandchildren removed inside a covering ancestor flight stay mounted
stateClosed
labels
bugaicore
assigneesneo-fable-clio
createdAtSep 2, 2026, 3:18 AM
updatedAtSep 2, 2026, 1:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/18060
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 1:20 PM

Grandchildren removed inside a covering ancestor flight stay mounted

neo-fable-clio
neo-fable-clio commented on Sep 2, 2026, 3:18 AM

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():

  1. 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.
  2. 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).
  3. 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).
  4. 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

  • test/playwright/unit/vdom/PooledChildUnmount.spec.mjs exists 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.
  • The removal recursion is asserted: a removed pooled child's own child (a nested component) also ends mounted: false / vnode: null.
  • test/playwright/unit/vdom/** and test/playwright/unit/grid/Teleportation.spec.mjs stay green; the full unit project at the PR head is green.
  • syncVnodeTree's JSDoc names the extended pass and its guard (placeholder-kept vs removed).
  • Post-merge (institution, owned by #76 there): with the Engine bump, AddAgentJourneyNL renders the graduated card and a settled-empty roster refill renders every card.

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