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):
- A
container.Base holds one form.field.Text (value: 'a'), rendered and mounted.
container.updateDepth = -1, a cls change, update() — the parent's render collects and is held in the air.
- The user types:
field.onInputValueChange({value: 'ab'}), then {value: 'abc'}.
- 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).
- 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.
- 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
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"
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.mjsstill lost keystrokes under load: 2 of 120 runs (--workers=10 --repeat-each=60), one of themtabbd away— anelost 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@29b2040133plus #18910's fix; the typed text has no spaces, so that producer is out of the picture). Fixturetest/playwright/component/apps/grid-cell-editing, pooled grid: double-clickc3of record 3, then type 36 characters one by one. Instrument: thevaluesetter ofHTMLInputElement.prototypewrapped on the main thread — every programmatic write goes through it, the user's typing does not.input.valuewritesstore.get(3).set({c4})) — the Row re-renders around the open editor…wxyz012345, wrote…uvwx…wxyz0123459(lost678),…nopqr0123456789(loststuvwxyz)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@3bd9c46e8fplus #18910's fix (which does not touch this path), deterministic (useVdomWorker: false,VdomHelper.updateBatchheld open — the harness ofunit/form/field/InputValueSync.spec.mjs):container.Baseholds oneform.field.Text(value: 'a'), rendered and mounted.container.updateDepth = -1, aclschange,update()— the parent's render collects and is held in the air.field.onInputValueChange({value: 'ab'}), then{value: 'abc'}.Value deltas sent for the field's input:
['abc']— a write of a value the DOM already holds. WithupdateDepth = 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.Rowrenders withupdateDepth: -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.mjsexecuteVdomUpdate(): after the response, only the batch's own components getcomponent.vnode = vnodeandcomponent.resolveVdomUpdate({deltas, vnode}, mergedChildIds).component.vnode = vnode→afterSetVnode→syncVnodeTree(): every child component found in the landed tree getscomponent._vnode = …silently — the vnode that render was diffed against, from before the typing.form.field.Text#resolveVdomUpdateis where #18792 restoresreportedInputValueinto a landed vnode (unless that render wrote the input's value itself, read offdata.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.CheckBoxmirrors the same repair (#18817), so it should have the same gap. Unverified.The Fix
A fork; my recommendation is (1).
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(andCheckBox) 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.deltasin the distribution loop);syncVnodeTree()is reached through thevnodesetter without them, so they need a way down.Decision Record impact
none
Acceptance Criteria
dev.dev. Beside #18792's arms.CheckBoxis measured against the same reproducer, and either fixed here or shown not to need it.unitandcomponenttiers stay green.Out of Scope
Avoided Traps
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"