LearnNewsExamplesServices
Frontmatter
id18914
titleA covering parent render undoes a text field's input sync
stateClosed
labels
bugaicore
assigneesneo-opus-grace
createdAtSep 18, 2026, 5:59 PM
updatedAtSep 18, 2026, 6:49 PM
githubUrlhttps://github.com/neomjs/neo/issues/18914
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 6:49 PM

A covering parent render undoes a text field's input sync

neo-fable
neo-fable commented on Sep 18, 2026, 5:59 PM

Context

Found while fixing #18910. That ticket closes one producer of "a render writes an older value over what the user is typing" (the trimmed vdom). With its fix applied, the two typing arms of test/playwright/e2e/grid/CellEditingTabSuspended.spec.mjs still lost keystrokes under load: 2 of 120 runs (--workers=10 --repeat-each=60), one of them tabbd away — an e lost in the middle of a word, no space involved. CI has shown the same red on unrelated PRs today (#18891, #18897).

#18792 / PR #18798 repaired this race for a text field's own render. A render of a parent that covers the field reopens it.

The Problem

Measured, e2e — real Chromium, real workers, one Playwright worker, no artificial load, 3 of 3 runs (dev@29b2040133 plus #18910's fix; the typed text has no spaces, so that producer is out of the picture). Fixture test/playwright/component/apps/grid-cell-editing, pooled grid: double-click c3 of record 3, then type 36 characters one by one. Instrument: the value setter of HTMLInputElement.prototype wrapped on the main thread — every programmatic write goes through it, the user's typing does not.

While typing programmatic input.value writes final text
the row is idle (control) 0 intact, 3 of 3
after each key, one store push onto ANOTHER field of the edited record (store.get(3).set({c4})) — the Row re-renders around the open editor 2–4 per run, every one an OLDER value over newer typing: had …wxyz012345, wrote …uvwx corrupted 2 of 3: …wxyz0123459 (lost 678), …nopqr0123456789 (lost stuvwxyz)

That is a live grid with a user typing in one of its rows — the engine's flagship case. #18843's store-activity arms are green because they push while nobody types.

The mechanism, isolated in the unit runtime, dev@3bd9c46e8f plus #18910's fix (which does not touch this path), deterministic (useVdomWorker: false, VdomHelper.updateBatch held open — the harness of unit/form/field/InputValueSync.spec.mjs):

  1. A container.Base holds one form.field.Text (value: 'a'), rendered and mounted.
  2. container.updateDepth = -1, a cls change, update() — the parent's render collects and is held in the air.
  3. The user types: field.onInputValueChange({value: 'ab'}), then {value: 'abc'}.
  4. Release; both settle.

Value deltas sent for the field's input: ['abc'] — a write of a value the DOM already holds. With updateDepth = 1 (the parent's render does not cover the field): [].

In the browser that write is node.value = 'abc', landing a round trip later: everything typed in between is gone, and the caret jumps to the end.

Every grid editor lives in that situation: grid.Row renders with updateDepth: -1 (src/grid/Row.mjs:72), so each Row render covers its editor — the selection toggles of the activating double-click, a store push onto the edited record, a scroll re-slotting the row.

The Architectural Reality

  • src/mixin/VdomLifecycle.mjs executeVdomUpdate(): after the response, only the batch's own components get component.vnode = vnode and component.resolveVdomUpdate({deltas, vnode}, mergedChildIds).
  • component.vnode = vnode → afterSetVnode → syncVnodeTree(): every child component found in the landed tree gets component._vnode = … silently — the vnode that render was diffed against, from before the typing.
  • form.field.Text#resolveVdomUpdate is where #18792 restores reportedInputValue into a landed vnode (unless that render wrote the input's value itself, read off data.deltas). It never runs for a covered child, so the child's vnode now holds an older input value than the DOM, and the field's next render — there is one per keystroke — writes its value.
  • form.field.CheckBox mirrors the same repair (#18817), so it should have the same gap. Unverified.

The Fix

A fork; my recommendation is (1).

  1. One rule for own and covering renders. syncVnodeTree() tells each child it synced that a render landed, with that render's deltas — a protected hook on the child, empty by default. Text (and CheckBox) move their restore into it and keep the "unless this render wrote my value" test exactly as it is. The deltas are in hand one frame up (response.deltas in the distribution loop); syncVnodeTree() is reached through the vnode setter without them, so they need a way down.
  2. The field re-asserts its report before each of its own renders collects. Smaller, but blind to the covering render's own payload: a deep parent render diffs the field's stale vnode itself.

Decision Record impact

none

Acceptance Criteria

  • AC-1: e2e, the measurement above as an arm: typing into a grid editor while its record receives store pushes ends with the typed text, and no programmatic write ever reaches the input's value; red on dev.
  • AC-1b: the unit reproducer below sends no value delta for the input; red on dev. Beside #18792's arms.
  • AC-2: CONTROL — a value set in code on the child while the parent's render is in the air still reaches the DOM.
  • AC-3: CONTROL — when the covering render wrote the input's value itself (the child's update merged into it), the next render restores what the user typed, as #18792's third arm states for the field's own render.
  • AC-4: CheckBox is measured against the same reproducer, and either fixed here or shown not to need it.
  • AC-5: the hook costs a covered child without a report nothing beyond the call; unit and component tiers stay green.

Out of Scope

  • #18910 (the trimmed-vdom producer).
  • The e2e typing arms themselves: they type like a user, and stay as they are.

Avoided Traps

  • Deciding without the deltas, by comparing the landed vnode's value with the report: a stale collection and a code-set value written by that render look the same from there. The deltas are what tells them apart, and #18792 already reads them.

Related

#18792 / PR #18798 · #18817 · #18910 · #18883 / PR #18892 (the arms that surfaced it) · #18762

Live latest-open sweep: checked the latest 20 open issues at 2026-09-18T16:05Z plus all-state searches (syncVnodeTree / parent render / text field vnode); nearest are #18792 and #18817, both closed, both the own-render case. A2A sweep (latest 12, all read-states): no claim on this scope. Memory Core sweep (typing overwritten / input value / render): no prior decision on covering renders. Own open assignments: #18910, #15252 — #18910 is the sibling producer, scoped apart above.

handoff: @neo-opus-grace — author of the restore this extends (#18798); the fork above is hers to settle, and an Opus seat is the cheap one for an engine-path change with a deterministic reproducer. I take it if she passes.

Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11

Retrieval Hint: "covering parent render syncVnodeTree silent vnode swap reportedInputValue restore never runs grid Row updateDepth -1"