Context
Two merged e2e arms of mine (test/playwright/e2e/grid/CellEditingTabSuspended.spec.mjs:90 and :105) went red on unrelated PRs today — defect-notes from @neo-opus-ada (PR #18897, run 35359020011) and @neo-opus-grace (PR #18891, job 105646334298). The arms type tabbed away / tabbed back into a grid editor; CI committed tabbedaway and tabbedy. Reproduced locally under load (--workers=10 --repeat-each=30, 1 of 60): tabbedck.
All three sightings revert to exactly tabbed — the loss always starts at the space. The arms are innocent: they type a space the way a user does. The defect is in form.field.Text, and any text field in any app has it.
Correction, 2026-09-18T16:00Z: this is one of TWO producers of that red. With this ticket's fix applied, 120 loaded runs of the two typing arms still lost keystrokes twice, once in the middle of a word. The second producer — a covering parent render undoing #18792's sync — is #18914, with its own deterministic reproducer. This ticket closes the trimmed-vdom producer only.
The Problem
Measured, unit runtime, dev@98dd1d7d76, deterministic (the harness of test/playwright/unit/form/field/InputValueSync.spec.mjs):
field.onInputValueChange({value: 'ab'}), render settles.
field.onInputValueChange({value: 'ab '}) — the user typed a space.
- The next render of the field, for any reason, carries the delta
value: 'ab' for the input.
In the browser that delta is node.value = 'ab': it erases the space, and everything typed between the render's collection and its landing, and moves the caret to the end. With the App Worker busy (CI load, a slow machine, a grid whose rows re-render on streamed data) the window is wide enough to lose several characters: tabbed ba|ck → tabbed → tabbedck.
Why: onInputValueChange() writes the DOM's raw value into the input's vnode ('ab '), then trims it before it becomes inputValue / value ('ab'). afterSetInputValue() puts that trimmed value into the vdom. After a space at either end the vdom ('ab') and the vnode ('ab ') disagree about a node the DOM already holds correctly, so every render diffs them and writes the trimmed value back. While someone types there is nearly always a render on its way: afterSetValue() calls update() per keystroke.
#18792 closed a neighbouring producer of the same symptom (a landing render replacing the synced vnode). That fix restores the reported value into the landed vnode — which keeps this disagreement alive rather than ending it.
The Architectural Reality
src/form/field/Text.mjs onInputValueChange(): inputVNode.attributes.value = inputValue; me.reportedInputValue = inputValue (raw), then inputValue = inputValue.trim(), then me.inputValue = me.inputValueAdjustor(inputValue).
afterSetInputValue(): me.getInputEl().value = me.containsFocus ? value : me.inputValueRenderer(value) — the trimmed value. For a trailing space inputValue does not change at all, so nothing rewrites the vdom and it keeps the older spelling.
onFocusLeave() already trims me.value and renders — leaving the field is where the DOM is meant to lose those spaces, and today it loses them through the very same disagreement.
reportedInputValue (#18792) is exactly "what the DOM shows right now".
inputValueAdjustor() subclasses (Currency, …) rely on the opposite case: when the adjusted value is NOT a spelling of what the DOM reported, the render must write it (reverting a rejected input). That has to keep working.
The Fix
Keep value trimmed — its public meaning does not change. Make the input's vdom say what the DOM shows while the field has focus: when the reported value, trimmed, equals the field's input value, the vdom carries the reported spelling; otherwise (an adjustor changed it, a value set in code) the computed value, as today. onInputValueChange() applies it also when inputValue did not change (the trailing space). onFocusLeave() puts the rendered value back, so leaving the field still shows the trimmed text.
Decision Record impact
none
Acceptance Criteria
Out of Scope
form.field.TextArea: its vnode sync looks for a node named input and would not find a textarea — unverified, a separate sighting.
- The second flake reported with it (
KeyboardNavigationScroll.spec.mjs:40, ends stuck on c12 outside the visible width under load): different surface, tracked separately.
Avoided Traps
- Dropping the trim from
onInputValueChange. It would make vdom, vnode and DOM agree, and change what value means while typing: an Enter-commit in a grid editor, or a form submitted with Enter, would carry the spaces, because neither passes through onFocusLeave.
- Changing the e2e arms to drafts without a space. That hides a user-facing data loss from the only tier that found it.
Related
#18792 / PR #18798 (the landing-render producer) · #18817 (its checkbox twin) · #18883 / PR #18892 (the arms that surfaced it) · #18762
Live latest-open sweep: checked the latest 20 open issues at 2026-09-18T15:50Z plus all-state searches (trim / overwritten typing / reportedInputValue / onInputValueChange); nearest are #18792 and #18817, both closed, both the landing-render producer. A2A sweep (latest 12, all read-states): the two defect-notes that reported the symptom, no claim. Memory Core sweep: no prior decision on the trim. Own open assignments: #18865, #15252 — no overlap.
Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "text field trailing space trimmed vdom raw vnode value delta overwrites typing"
Context
Two merged e2e arms of mine (
test/playwright/e2e/grid/CellEditingTabSuspended.spec.mjs:90and:105) went red on unrelated PRs today — defect-notes from @neo-opus-ada (PR #18897, run 35359020011) and @neo-opus-grace (PR #18891, job 105646334298). The arms typetabbed away/tabbed backinto a grid editor; CI committedtabbedawayandtabbedy. Reproduced locally under load (--workers=10 --repeat-each=30, 1 of 60):tabbedck.All three sightings revert to exactly
tabbed— the loss always starts at the space. The arms are innocent: they type a space the way a user does. The defect is inform.field.Text, and any text field in any app has it.Correction, 2026-09-18T16:00Z: this is one of TWO producers of that red. With this ticket's fix applied, 120 loaded runs of the two typing arms still lost keystrokes twice, once in the middle of a word. The second producer — a covering parent render undoing #18792's sync — is #18914, with its own deterministic reproducer. This ticket closes the trimmed-vdom producer only.
The Problem
Measured, unit runtime,
dev@98dd1d7d76, deterministic (the harness oftest/playwright/unit/form/field/InputValueSync.spec.mjs):field.onInputValueChange({value: 'ab'}), render settles.field.onInputValueChange({value: 'ab '})— the user typed a space.value: 'ab'for the input.In the browser that delta is
node.value = 'ab': it erases the space, and everything typed between the render's collection and its landing, and moves the caret to the end. With the App Worker busy (CI load, a slow machine, a grid whose rows re-render on streamed data) the window is wide enough to lose several characters:tabbed ba|ck→tabbed→tabbedck.Why:
onInputValueChange()writes the DOM's raw value into the input's vnode ('ab '), then trims it before it becomesinputValue/value('ab').afterSetInputValue()puts that trimmed value into the vdom. After a space at either end the vdom ('ab') and the vnode ('ab ') disagree about a node the DOM already holds correctly, so every render diffs them and writes the trimmed value back. While someone types there is nearly always a render on its way:afterSetValue()callsupdate()per keystroke.#18792 closed a neighbouring producer of the same symptom (a landing render replacing the synced vnode). That fix restores the reported value into the landed vnode — which keeps this disagreement alive rather than ending it.
The Architectural Reality
src/form/field/Text.mjsonInputValueChange():inputVNode.attributes.value = inputValue; me.reportedInputValue = inputValue(raw), theninputValue = inputValue.trim(), thenme.inputValue = me.inputValueAdjustor(inputValue).afterSetInputValue():me.getInputEl().value = me.containsFocus ? value : me.inputValueRenderer(value)— the trimmed value. For a trailing spaceinputValuedoes not change at all, so nothing rewrites the vdom and it keeps the older spelling.onFocusLeave()already trimsme.valueand renders — leaving the field is where the DOM is meant to lose those spaces, and today it loses them through the very same disagreement.reportedInputValue(#18792) is exactly "what the DOM shows right now".inputValueAdjustor()subclasses (Currency, …) rely on the opposite case: when the adjusted value is NOT a spelling of what the DOM reported, the render must write it (reverting a rejected input). That has to keep working.The Fix
Keep
valuetrimmed — its public meaning does not change. Make the input's vdom say what the DOM shows while the field has focus: when the reported value, trimmed, equals the field's input value, the vdom carries the reported spelling; otherwise (an adjustor changed it, a value set in code) the computed value, as today.onInputValueChange()applies it also wheninputValuedid not change (the trailing space).onFocusLeave()puts the rendered value back, so leaving the field still shows the trimmed text.Decision Record impact
none
Acceptance Criteria
onInputValueChangewith a trailing, and with a leading, space, no render writes the input's value; red ondev. Unit tier,InputValueSync.spec.mjs.field.valuestays trimmed while typing, and theuserChangepayloads do not change.inputValueAdjustorthat rejects what was typed still gets its value written.form, componentform+grid, e2egridstay green.Out of Scope
form.field.TextArea: its vnode sync looks for a node namedinputand would not find atextarea— unverified, a separate sighting.KeyboardNavigationScroll.spec.mjs:40, ends stuck onc12outside the visible width under load): different surface, tracked separately.Avoided Traps
onInputValueChange. It would make vdom, vnode and DOM agree, and change whatvaluemeans while typing: an Enter-commit in a grid editor, or a form submitted with Enter, would carry the spaces, because neither passes throughonFocusLeave.Related
#18792 / PR #18798 (the landing-render producer) · #18817 (its checkbox twin) · #18883 / PR #18892 (the arms that surfaced it) · #18762
Live latest-open sweep: checked the latest 20 open issues at 2026-09-18T15:50Z plus all-state searches (trim / overwritten typing /
reportedInputValue/onInputValueChange); nearest are #18792 and #18817, both closed, both the landing-render producer. A2A sweep (latest 12, all read-states): the two defect-notes that reported the symptom, no claim. Memory Core sweep: no prior decision on the trim. Own open assignments: #18865, #15252 — no overlap.Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "text field trailing space trimmed vdom raw vnode value delta overwrites typing"