LearnNewsExamplesServices
Frontmatter
id18885
titleAn open grid edit is lost when its column changes body, and the unlock wedges a body
stateClosed
labels
bugaigrid
assigneesneo-opus-ada
createdAtSep 18, 2026, 3:17 PM
updatedAtSep 18, 2026, 5:07 PM
githubUrlhttps://github.com/neomjs/neo/issues/18885
authorneo-opus-ada
commentsCount0
parentIssue18762
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 5:07 PM

An open grid edit is lost when its column changes body, and the unlock wedges a body

neo-opus-ada
neo-opus-ada commented on Sep 18, 2026, 3:17 PM

Context

Leaf of #18762, found by #18867's witness arms. The session is keyed by record id and dataField, and grid.Row#applyRendererOutput embeds the editor into whichever body renders the column. A runtime lock or unlock of the edited column moves it between bodies while the editor is open. Measured on #grid-cell-editing-pooled (test/playwright/component/apps/grid-cell-editing), locking c3 from inside the App Worker (no pointer, so no focus change ends the edit first):

Open edit on record 3's c3, then Observed
lock c3 to the start the editor follows into the start body, with its draft, and survives a vertical pool round trip
lock c3 to the end no cell embodies the editor. A vertical round trip does not bring it back
lock to the start, then unlock no cell embodies the editor, and App Worker: vdom update wedged: "neo-grid-body-4" has been in-flight for over 5000ms. Its reply was likely lost — the component will not update again

Controls with no open edit: locking and unlocking c3 renders every c3 cell in the new body in both directions. Unlocking from the end places c3 after c38, so it renders once scrolled right (by design, not this defect).

The Mechanism

Measured at 0887e08dd8 (#18887's head), with the App Worker's plugin methods, VDomUpdate registrations and Helper.updateBatch wrapped, and the main thread's DeltaUpdates logged. neo-grid-body-4 is the center body, -5 the start and -6 the end. Three defects compound:

  1. The editor is lost in the diff. One batch re-renders all three bodies. Its deltas recycle the center cell that held the editor, {id: 'neo-grid-body-4__row-2__cell-2', innerHTML: 'r3c4'}, and only then move the editor, {action: 'moveNode', id: 'neo-textfield-1', parentId: 'neo-grid-body-6__row-2__c3'}. The innerHTML detaches the editor and focus drops to BODY, so the move finds no node. The start direction survives because the start body diffs before the center one, so its move lands before the wipe. vdom.Helper already defers removals behind moves (deltas.default.concat(deltas.remove)). An innerHTML switch that replaces old children, including one the same diff moves, stays in the default phase.
  2. The blur commits. The detached editor's focusLeave reaches onEditorFocusLeave. isEmbodied() answers from the column's new body, which renders the record, so it returns true: {embodied: true, locked: 'end'}. completeEdit() writes the draft and destroys the editor. The unlock does the same through focusMove, with the editor already unmounted (mounted: false).
  3. The wedge. The batch in (1) is the center body's own flight, escalated to the viewport. The editor had merged into it (its neo-is-dirty update was in flight), then Row#applyRendererOutput re-parented it to the end body's row. VDomUpdate#getMergedChildIds walks a bridge path from each merged child up to the owner. From the re-parented editor that walk never meets neo-grid-body-4, so it climbs to the root and adds every ancestor. collectPayloads() collects the viewport's full tree, the collision filter drops the body's own payload as covered, and the reply carries a vnode for the viewport only. The body's resolveVdomUpdate() never runs, so its registration never clears.

The Fix

An open edit is held for the duration of any lock change, not only one of its own column: a column changing body also shifts the pool slots of the columns beside it. While it is held, no Row embeds the editor, the blur its removal causes is neutral, and once the bodies have swapped columns, the body rendering the column embeds it again. The draft lives in the editor instance, which survives, as it does when a sort or scroll takes the record out of the row pool. This removes all three triggers here: no diff has to move the editor, and nothing re-parents it while it is merged into a flight. ("Held" rather than "suspended": #18892 uses "suspended" for an edit whose cell left the row pool.)

(1) and (3) are engine defects that do not need a grid to fire: #18900 and #18899, each with its own witness.

Acceptance Criteria

  • With an edit open, locking the column to either end and unlocking it keeps the editor embodied in the column's current body, with its draft.
  • No vdom update wedged or other worker error during either direction.
  • The two test.fail() arms in test/playwright/e2e/grid/CellEditingLockedBodies.spec.mjs flip to passing arms.
  • The fix names its mechanism, measured rather than inferred, in the PR.

Out of Scope

  • Where an unlocked column lands in the column order.
  • Locks started by pointer (a header drag moves focus and commits the edit first).
  • Focus returning to the re-embedded editor: the same interaction-contract scope as a reprojection after sort or filter.

Related

#18762 · #18867 · #18764 · #12946

Decision Record impact: none.

Live latest-open sweep: latest 20 open issues at 2026-09-18T13:17Z; all-state searches locked column editor and wedged vdom update: no equivalent. A2A lane-claims (latest 30): none on this scope. Memory Core: the runtime-lock body lifecycle (June, #9872) and #18764's retired-editor render (the closest class), no decision on moving an open edit. Own open assignments: #18839 (PR #18857), #18867.

Origin Session ID: 6ed6621f-e2e4-4b9d-9a95-744679cb3c59 Retrieval Hint: "grid cell editing runtime column lock unlock open editor lost vdom update wedged body merged child bridge path innerHTML moveNode"