Reviewing #19509, I found that a sparse ancestor which absorbs a dense child root loses the root's silent descendants. #19509 repairs that case. The same falsifier showed an older sibling case on dev: a finite dense ancestor absorbs a dense child whose own scope reaches past the ancestor's depth. @neo-gpt-sophie reproduced it independently on a1f4695af0 and on #19509's repair (defect-note, 2026-10-09 16:12Z), and asked me to own it here.
Sophie's fork read (comment) then proved the same overlap on the sparse path #19509 shipped, in a real browser and in both worker modes. The setup is a root sparse at depth 2 over a host whose child hides, with a header the host just gained. The result is two insertNode deltas for the header and two header nodes in the DOM.
The Problem
Component#hide() with hideMode removeDom sets the component's vdom.removeDom = true, then sets parent.updateDepth = 2 and parent.denseUpdate = true, and calls parent.update(). That parent's dense update is a child root of whatever ancestor update is pending. Both ancestor shapes mishandle it:
A finite dense ancestor at depth 2, distance 1, absorbs it: the collision filter compares the ancestor's reach with the child root (2 > 1), never with the child's own depth. The ancestor's payload prunes the parent's children at its depth boundary, so the removeDom never reaches the DOM. The component stays hidden in state but mounted, and no removeNode delta is emitted.
A sparse ancestor (since #19509) keeps the dense child emitted, but its own payload still expands that child, because it is allowlisted. The child's subtree is diffed twice: every structural insert in it is emitted twice.
The Architectural Reality
Design authority: VdomLifecycle.executeVdomUpdate publishes a disjoint batch: "Instead of merging child updates into the parent's VDOM tree … we collect all merged child updates and send them as a batch of disjoint payloads." Today it decides what each payload holds per pair: parentDepth === -1 || parentDepth > distance for dense ancestors, and the allowlist for sparse ones (#19509). Payloads are built during collection, before any root's fate is known, so an emitted child can never be cut out of an ancestor's tree.
TreeBuilder.#buildTree expands a component at distance k from a dense depth-d payload only when d > k. A sparse payload expands only its allowlist, at any distance. A pruned reference is a {componentId, neoIgnore: true} placeholder in the VDOM and the vnode tree alike; Helper.createDeltas needs that symmetry, and syncVnodeTree leaves a placeholder's component untouched.
getVdomUpdatePayload resets updateDepth and denseUpdate. Container's afterSetNeedsVdomUpdate(false) clears its descendants' flags, so payloads must keep their collection order.
Producers of dense child updates: Component#hide(), Fieldset#updateLegend(), and grid.View's scroll update.
The Fix
Plan the batch's boundaries from the roots it actually emits, for sparse and dense ancestors alike (option (b), with Sophie's refinement):
Collect every scope first: its depth and the merged ids its payload is built with, captured before any payload resets them.
Decide ancestors first. A collected component is absorbed only when its nearest emitted ancestor's payload carries its whole scope; otherwise it is emitted.
A full-depth ancestor carries everything.
A sparse ancestor reaches only its allowlist and carries a depth-1 or sparse root (#19509's rule).
A finite dense ancestor at depth d reaches a component at distance k when d > k. It carries a dense root of depth c only when k + c ≤ d.
Build every payload with the emitted roots as boundaries.TreeBuilder leaves an emitted root's reference as a neoIgnore placeholder, in the VDOM and the vnode tree alike, so exactly one payload diffs each subtree.
This adds no wire grammar. A batch with no emitted root under another builds the same payloads as before, in the same order.
Contract Ledger
All three are new arguments or methods on core VDOM surfaces; the source of authority for each is this ticket. Each row was checked at #19525's head f424895b1f: the signatures, every caller (git grep over src, apps, examples and the unit specs), and the absence of any getVdomUpdatePayload override.
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
Neo.util.vdom.TreeBuilder#getVdomTree(vdom, depth=-1, mergedChildIds=null, boundaryIds=null) and #getVnodeTree(vnode, depth=-1, mergedChildIds=null, boundaryIds=null): new optional last argument
A component reference whose id is in boundaryIds becomes {...reference, neoIgnore: true} at any depth, before the depth and allowlist rules. Its subtree is not built.
Omitted or null: output is unchanged. The existing callers pass none: ComponentService's tree readers and initVnode's first render. A caller must pass the same set to both trees, because Helper.createDeltas needs matching cuts.
JSDoc on both methods
SparseUpdates.spec.mjs › … ancestor and its dense child render overlapping structure once (both adoption orders). An asymmetric cut would add deltas, and each arm's follow-up update must emit none. TreeBuilder.spec.mjs and AsymmetricUpdates.spec.mjs call it without the argument, unchanged.
Neo.mixin.VdomLifecycle#getVdomUpdatePayload(mergedChildIds, depth, boundaryIds=null): new optional third argument
Passes boundaryIds to both tree builds. executeVdomUpdate, its only caller, passes the batch's emitted roots when it emits more than one, and null otherwise.
Omitted or null: the payload is unchanged. The method still resets needsVdomUpdate, updateDepth and denseUpdate, so every collected component is built once, in collection order. That includes absorbed components, whose payload is discarded.
JSDoc
SparseUpdates.spec.mjs (37 arms); the full unit tier
Called on the nearest emitted ancestor. Returns true when that ancestor's payload, captured at depth (with allowlist ids when sparse), carries a collected root distance below it. A full-depth (-1) payload carries everything. A sparse payload reaches only ids and carries a depth-1 or sparse root. A dense payload reaches distance < depth and carries a dense root only when distance + rootDepth <= depth.
A full-depth root (rootDepth-1) is carried only by a full-depth payload. An override changes absorption for that ancestor's class only.
JSDoc; executeVdomUpdate's step 3 links it
SparseUpdates.spec.mjs › dense ancestor preserves … hide at distance 1, A … ancestor renders its distance-N descendant once, Collision filtering preserves a dirty sibling, dense ancestor renders a sparse child and its deep allowlisted descendant once; SparseUpdatesNL.spec.mjs hide matrix
Acceptance Criteria
AC-1 Under a finite dense depth-2 ancestor, Component#hide()'s parent update at distance 1 removes the hidden child: one removeNode delta, and the vnode is dropped. The arm is in SparseUpdates.spec.mjs, red before the fix.
AC-2 Sophie's case: a sparse depth-2 root, with a host that gains a header while its child hides. It emits exactly one insertNode for the header and one removeNode. The unit arm is red before the fix.
AC-3 AC-1 and AC-2 are in the browser matrix (SparseUpdatesNL.spec.mjs) in both worker modes, at the DOM: one header node, the hidden node gone. This follows the operator's E2E rule for VDOM behaviour.
AC-4 Absorption is unchanged where the ancestor carries the child: a depth-1 child under the dense ancestor, every descendant under a full-depth ancestor, and the existing sparse, bridge and late-flight cases. The related VDOM unit tests pass.
AC-5 Edge controls (Sophie's): a sparse child whose allowlist reaches beyond its nominal depth under a dense ancestor; parent and child structural changes that partially overlap; and both adoption orders of the emitted roots. Each renders every change exactly once, with coherent vnodes.
Out of Scope
Widening owners through getAdjustedUpdateDepth (option (c)): it applies a deeper scope to sibling branches.
The Institution's post-merge rail red (#19522, Sophie's). This repair is not assumed to fix it.
Live latest-open sweep: the latest 20 open issues at 2026-10-09 16:16Z, plus title searches for "dense ancestor" and "collision filter". No equivalent: #8838, the original collision-filtering refinement, is closed, and #19502 is the sparse case.
A2A in-flight claim sweep: Sophie's 16:12Z message hands this case to me; no other claim.
MC sweep: my #19509 review (5472292941) and Sophie's defect-note; no prior decision on dense whole-scope containment.
Own-assignment sweep: no overlap.
Context
Reviewing #19509, I found that a sparse ancestor which absorbs a dense child root loses the root's silent descendants. #19509 repairs that case. The same falsifier showed an older sibling case on
dev: a finite dense ancestor absorbs a dense child whose own scope reaches past the ancestor's depth. @neo-gpt-sophie reproduced it independently ona1f4695af0and on #19509's repair (defect-note, 2026-10-09 16:12Z), and asked me to own it here.Sophie's fork read (comment) then proved the same overlap on the sparse path #19509 shipped, in a real browser and in both worker modes. The setup is a root sparse at depth 2 over a host whose child hides, with a header the host just gained. The result is two
insertNodedeltas for the header and two header nodes in the DOM.The Problem
Component#hide()with hideModeremoveDomsets the component'svdom.removeDom = true, then setsparent.updateDepth = 2andparent.denseUpdate = true, and callsparent.update(). That parent's dense update is a child root of whatever ancestor update is pending. Both ancestor shapes mishandle it:2 > 1), never with the child's own depth. The ancestor's payload prunes the parent's children at its depth boundary, so theremoveDomnever reaches the DOM. The component stays hidden in state but mounted, and noremoveNodedelta is emitted.The Architectural Reality
VdomLifecycle.executeVdomUpdatepublishes a disjoint batch: "Instead of merging child updates into the parent's VDOM tree … we collect all merged child updates and send them as a batch of disjoint payloads." Today it decides what each payload holds per pair:parentDepth === -1 || parentDepth > distancefor dense ancestors, and the allowlist for sparse ones (#19509). Payloads are built during collection, before any root's fate is known, so an emitted child can never be cut out of an ancestor's tree.TreeBuilder.#buildTreeexpands a component at distance k from a dense depth-d payload only when d > k. A sparse payload expands only its allowlist, at any distance. A pruned reference is a{componentId, neoIgnore: true}placeholder in the VDOM and the vnode tree alike;Helper.createDeltasneeds that symmetry, andsyncVnodeTreeleaves a placeholder's component untouched.getVdomUpdatePayloadresetsupdateDepthanddenseUpdate. Container'safterSetNeedsVdomUpdate(false)clears its descendants' flags, so payloads must keep their collection order.Component#hide(),Fieldset#updateLegend(), andgrid.View's scroll update.The Fix
Plan the batch's boundaries from the roots it actually emits, for sparse and dense ancestors alike (option (b), with Sophie's refinement):
TreeBuilderleaves an emitted root's reference as aneoIgnoreplaceholder, in the VDOM and the vnode tree alike, so exactly one payload diffs each subtree.This adds no wire grammar. A batch with no emitted root under another builds the same payloads as before, in the same order.
Contract Ledger
All three are new arguments or methods on core VDOM surfaces; the source of authority for each is this ticket. Each row was checked at #19525's head
f424895b1f: the signatures, every caller (git grepoversrc,apps,examplesand the unit specs), and the absence of anygetVdomUpdatePayloadoverride.Neo.util.vdom.TreeBuilder#getVdomTree(vdom, depth=-1, mergedChildIds=null, boundaryIds=null)and#getVnodeTree(vnode, depth=-1, mergedChildIds=null, boundaryIds=null): new optional last argumentboundaryIdsbecomes{...reference, neoIgnore: true}at any depth, before the depth and allowlist rules. Its subtree is not built.null: output is unchanged. The existing callers pass none:ComponentService's tree readers andinitVnode's first render. A caller must pass the same set to both trees, becauseHelper.createDeltasneeds matching cuts.SparseUpdates.spec.mjs›… ancestor and its dense child render overlapping structure once(both adoption orders). An asymmetric cut would add deltas, and each arm's follow-up update must emit none.TreeBuilder.spec.mjsandAsymmetricUpdates.spec.mjscall it without the argument, unchanged.Neo.mixin.VdomLifecycle#getVdomUpdatePayload(mergedChildIds, depth, boundaryIds=null): new optional third argumentboundaryIdsto both tree builds.executeVdomUpdate, its only caller, passes the batch's emitted roots when it emits more than one, andnullotherwise.null: the payload is unchanged. The method still resetsneedsVdomUpdate,updateDepthanddenseUpdate, so every collected component is built once, in collection order. That includes absorbed components, whose payload is discarded.SparseUpdates.spec.mjs(37 arms); the full unit tierNeo.mixin.VdomLifecycle#carriesScope(depth, ids, distance, rootId, rootDepth, rootSparse): new,@protectedtruewhen that ancestor's payload, captured atdepth(with allowlistidswhen sparse), carries a collected rootdistancebelow it. A full-depth (-1) payload carries everything. A sparse payload reaches onlyidsand carries a depth-1 or sparse root. A dense payload reachesdistance < depthand carries a dense root only whendistance + rootDepth <= depth.rootDepth-1) is carried only by a full-depth payload. An override changes absorption for that ancestor's class only.executeVdomUpdate's step 3 links itSparseUpdates.spec.mjs›dense ancestor preserves … hide at distance 1,A … ancestor renders its distance-N descendant once,Collision filtering preserves a dirty sibling,dense ancestor renders a sparse child and its deep allowlisted descendant once;SparseUpdatesNL.spec.mjshide matrixAcceptance Criteria
Component#hide()'s parent update at distance 1 removes the hidden child: oneremoveNodedelta, and the vnode is dropped. The arm is inSparseUpdates.spec.mjs, red before the fix.insertNodefor the header and oneremoveNode. The unit arm is red before the fix.SparseUpdatesNL.spec.mjs) in both worker modes, at the DOM: one header node, the hidden node gone. This follows the operator's E2E rule for VDOM behaviour.Out of Scope
getAdjustedUpdateDepth(option (c)): it applies a deeper scope to sibling branches.Related
#19509 · #19502 · #19522 · #19499 · #8890 · #8838
Live latest-open sweep: the latest 20 open issues at 2026-10-09 16:16Z, plus title searches for "dense ancestor" and "collision filter". No equivalent: #8838, the original collision-filtering refinement, is closed, and #19502 is the sparse case. A2A in-flight claim sweep: Sophie's 16:12Z message hands this case to me; no other claim. MC sweep: my #19509 review (5472292941) and Sophie's defect-note; no prior decision on dense whole-scope containment. Own-assignment sweep: no overlap.
Origin Session ID: fcc49158-63cf-4317-a407-741c5e5e3d8c Retrieval Hint: "disjoint batch boundary planning emitted roots neoIgnore dense ancestor whole scope"