Context
Found while measuring #18885, where a grid lock change lost an open cell editor. It reproduces without a grid, at the Helper, in a unit probe on dev@b6740acf61. Old tree: cell1 holds the input x, and cell2 holds text. New tree: cell1 holds text, and cell2 holds x. VdomHelper.update() answers:
[{id: 'cell1', innerHTML: 'a'}, {id: 'cell2', innerHTML: ''}, {action: 'moveNode', id: 'x', parentId: 'cell2'}]The Problem
vdom.Helper defers removals behind moves (deltas.default.concat(deltas.remove)), so a node moving out of a subtree leaves before the subtree goes. An innerHTML or textContent change that replaces a node's element children removes those children too. But compareAttributes() emits it in the default phase, in tree order. When one of the replaced children moves to a node the diff reaches later, the wipe lands first: the child is detached, and DeltaUpdates#moveNode finds no node for its id and moves nothing. A focused child also loses focus, since the wipe is a removal and not the atomic moveBefore().
#18885's main-thread trace shows it on a real DOM. The grid recycled the pooled cell that held the editor to another column, {id: 'neo-grid-body-4__row-2__cell-2', innerHTML: 'r3c4'}. Only then came {action: 'moveNode', id: 'neo-textfield-1', parentId: 'neo-grid-body-6__row-2__c3'}, which found the editor already gone (node: false, focus on BODY).
The Architectural Reality
src/vdom/Helper.mjs: the innerHTML and textContent cases of compareAttributes() push the node's delta to deltas.default. createDeltas() does not walk the old children of a node whose content becomes markup, so none of them is removed or moved from there. removeNode() writes to deltas.remove, and the result is deltas.default.concat(deltas.remove).
src/main/DeltaUpdates.mjs#moveNode: resolves the node by id. A detached node is not found.
- A pooled cell switching between a component child and text is the grid's everyday render: an editor, or any component column, inside a recycled slot.
The Fix
When a node's content switches to innerHTML or textContent while its old vnode has children, and one of its old descendants is present in the new tree, that node's delta joins the remove phase, behind every move. The common case keeps its place: a text change over a node whose old children do not move elsewhere.
Acceptance Criteria
Out of Scope
- The grid trigger, which #18885 removes by holding the editor out of every cell during a lock change.
VDomUpdate#getMergedChildIds()'s bridge walk, the other engine defect #18885 found (#18899).
Related
#18885 · #18897 · #18899
Decision Record impact: none.
Live latest-open sweep: the latest 20 open issues at 2026-09-18T14:44Z, and an all-state search for innerHTML moveNode: no equivalent. A2A: no claim on this scope.
Origin Session ID: 6ed6621f-e2e4-4b9d-9a95-744679cb3c59
Retrieval Hint: "vdom Helper innerHTML textContent replaces children before moveNode of a moved descendant, remove phase ordering"
Context
Found while measuring #18885, where a grid lock change lost an open cell editor. It reproduces without a grid, at the Helper, in a unit probe on
dev@b6740acf61. Old tree:cell1holds the inputx, andcell2holds text. New tree:cell1holds text, andcell2holdsx.VdomHelper.update()answers:[{id: 'cell1', innerHTML: 'a'}, {id: 'cell2', innerHTML: ''}, {action: 'moveNode', id: 'x', parentId: 'cell2'}]The Problem
vdom.Helperdefers removals behind moves (deltas.default.concat(deltas.remove)), so a node moving out of a subtree leaves before the subtree goes. AninnerHTMLortextContentchange that replaces a node's element children removes those children too. ButcompareAttributes()emits it in the default phase, in tree order. When one of the replaced children moves to a node the diff reaches later, the wipe lands first: the child is detached, andDeltaUpdates#moveNodefinds no node for its id and moves nothing. A focused child also loses focus, since the wipe is a removal and not the atomicmoveBefore().#18885's main-thread trace shows it on a real DOM. The grid recycled the pooled cell that held the editor to another column,
{id: 'neo-grid-body-4__row-2__cell-2', innerHTML: 'r3c4'}. Only then came{action: 'moveNode', id: 'neo-textfield-1', parentId: 'neo-grid-body-6__row-2__c3'}, which found the editor already gone (node: false, focus onBODY).The Architectural Reality
src/vdom/Helper.mjs: theinnerHTMLandtextContentcases ofcompareAttributes()push the node's delta todeltas.default.createDeltas()does not walk the old children of a node whose content becomes markup, so none of them is removed or moved from there.removeNode()writes todeltas.remove, and the result isdeltas.default.concat(deltas.remove).src/main/DeltaUpdates.mjs#moveNode: resolves the node by id. A detached node is not found.The Fix
When a node's content switches to
innerHTMLortextContentwhile its old vnode has children, and one of its old descendants is present in the new tree, that node's delta joins the remove phase, behind every move. The common case keeps its place: a text change over a node whose old children do not move elsewhere.Acceptance Criteria
moveNodeofxbeforecell1'sinnerHTML.innerHTMLchange whose old children move nowhere keeps its deltas and their order.unit/vdom/Helper,HelperNeoIgnore,FragmentMoves,DeltaGrammar) stay green.Out of Scope
VDomUpdate#getMergedChildIds()'s bridge walk, the other engine defect #18885 found (#18899).Related
#18885 · #18897 · #18899
Decision Record impact: none.
Live latest-open sweep: the latest 20 open issues at 2026-09-18T14:44Z, and an all-state search for
innerHTML moveNode: no equivalent. A2A: no claim on this scope.Origin Session ID: 6ed6621f-e2e4-4b9d-9a95-744679cb3c59 Retrieval Hint: "vdom Helper innerHTML textContent replaces children before moveNode of a moved descendant, remove phase ordering"