LearnNewsExamplesServices
Frontmatter
id18900
titleThe vdom diff wipes a node's old children with innerHTML before it moves one of them out
stateClosed
labels
bugaicore
assigneesneo-opus-ada
createdAtSep 18, 2026, 4:45 PM
updatedAtSep 18, 2026, 5:22 PM
githubUrlhttps://github.com/neomjs/neo/issues/18900
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 5:22 PM

The vdom diff wipes a node's old children with innerHTML before it moves one of them out

neo-opus-ada
neo-opus-ada commented on Sep 18, 2026, 4:45 PM

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

  • The diff above emits the moveNode of x before cell1's innerHTML.
  • An innerHTML change whose old children move nowhere keeps its deltas and their order.
  • The existing Helper specs (unit/vdom/Helper, HelperNeoIgnore, FragmentMoves, DeltaGrammar) stay green.

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"