When text is already present in the hidden helper textarea (e.g. characters the user just typed, which were already sent to the PTY) and the user then starts an IME composition, Windows TSF IMEs (verified with Microsoft Pinyin) apply composition updates as a whole-value replacement: from the second insertCompositionText frame onward, the replaced range spans the entire textarea content — including the pre-existing text — not just the composing region.
CompositionHelper records the composition start offset exactly once, at compositionstart (_compositionPosition.start = selectionStart), and never rebases it. After the IME's whole-value replacement, that offset is stale by exactly the length of the absorbed pre-existing text, so _finalizeComposition's textarea.value.substring(...) silently drops the first N characters of the committed string.
Deterministic symptom: type N spaces, then compose and commit Chinese text → the first N committed characters are swallowed. With N ≥ commit length, the entire commit is lost.
Details
- Browser and browser version: Chromium via Electron 42.6.0 (Windows TSF path; not reproducible without a real TSF IME driving the events)
- OS version: Windows 11 (Microsoft Pinyin IME)
- xterm.js version: observed on
6.1.0-beta.220; confirmed still present by source inspection at current master / 6.1.0-beta.289 (b1aee19, identical gitHead)
Steps to reproduce
- On Windows with Microsoft Pinyin active, focus an xterm.js terminal.
- Type 3 spaces. (They are sent to the PTY normally and remain in the hidden helper textarea.)
- Type
nishi and commit the candidate 你是.
- Expected:
你是 is emitted via onData after the spaces. Actual: nothing is emitted — the commit is entirely swallowed. With 1 space, 是 is emitted instead of 你是 (first char dropped); the number of dropped characters always equals the number of pre-existing characters.
Root cause (traced at b1aee19)
-
compositionstart records _compositionPosition.start = selectionStart — with 3 pre-existing spaces, start = 3. selectionStart is read exactly once in the whole file; compositionupdate only re-reads selectionEnd, so start is never corrected afterwards.
-
Real event trace (DevTools instrumentation, condensed; ta = textarea value, sel/selEnd = selection):
keydown Space ×3 → each " " emitted to PTY; ta=" ", sel=3
compositionstart → pos.start=3 ta=" ", sel=3
compositionupdate "n" → insertCompositionText ta=" n"
compositionupdate "ni" → sel:0 selEnd:4 ← TSF selects the WHOLE value (spaces included)
and replaces it ta="ni"
... ta="ni'shi"
compositionend data:"你是" ta="你是", sel=0
_finalizeComposition pos={start:3,...}, ta="你是"
-
In _finalizeComposition's waitForPropagation branch, _isComposing is already false, so the else branch runs: currentCompositionSuffix is '' (nothing followed the caret at compositionstart), valueEnd = value.length = 2, and value.substring(3, Math.max(3, 2)) === "" → nothing is emitted. With shorter pre-text the substring drops exactly that many leading characters.
Relationship to existing issues
Regression-test-shaped repro (no IME hardware needed)
Following the harness pattern of the existing #5698 test in src/browser/input/CompositionHelper.test.ts (sketch — encodes the traced event shape above):
it('should not drop leading pre-existing text when the IME replaces the whole textarea value', done => {
// 3 chars typed before composing (already sent to the PTY) remain in the textarea
textarea.value = ' ';
textarea.selectionStart = 3;
textarea.selectionEnd = 3;
compositionHelper.compositionstart();
compositionHelper.compositionupdate({ data: 'n' } as CompositionEvent);
textarea.value = ' n';
setTimeout(() => {
// second TSF frame replaces the ENTIRE value (selection spanned 0..4 in the real trace)
compositionHelper.compositionupdate({ data: 'ni' } as CompositionEvent);
textarea.value = 'ni';
setTimeout(() => {
compositionHelper.compositionend();
textarea.value = '你是';
setTimeout(() => {
assert.equal(handledText, '你是'); // actual on master: '' — everything dropped
done();
}, 0);
}, 0);
}, 0);
});
Proposed fix
Stop deriving the committed string from offset-slicing the textarea in the compositionend-driven finalize path, and instead record the compositionend event's data (the DOM's authoritative committed string) and emit that in the waitForPropagation === true branch. The keydown-driven early-finalize path (waitForPropagation === false) keeps the existing slice, since no compositionend data exists yet there.
This is immune to any replacement geometry (offsets no longer matter), and sidesteps the trailing-suffix special case for the commit path as well. We ship this fix in a downstream Electron terminal app (as a patch on 6.1.0-beta.220) and have verified it with real Microsoft Pinyin hand-testing plus a synthetic regression suite covering single/multi-char commits, double-emission guards, English-candidate commits, and this replacement scenario.
Happy to open a PR with the fix and the regression test above.
When text is already present in the hidden helper textarea (e.g. characters the user just typed, which were already sent to the PTY) and the user then starts an IME composition, Windows TSF IMEs (verified with Microsoft Pinyin) apply composition updates as a whole-value replacement: from the second
insertCompositionTextframe onward, the replaced range spans the entire textarea content — including the pre-existing text — not just the composing region.CompositionHelperrecords the composition start offset exactly once, atcompositionstart(_compositionPosition.start = selectionStart), and never rebases it. After the IME's whole-value replacement, that offset is stale by exactly the length of the absorbed pre-existing text, so_finalizeComposition'stextarea.value.substring(...)silently drops the first N characters of the committed string.Deterministic symptom: type N spaces, then compose and commit Chinese text → the first N committed characters are swallowed. With N ≥ commit length, the entire commit is lost.
Details
6.1.0-beta.220; confirmed still present by source inspection at current master /6.1.0-beta.289(b1aee19, identicalgitHead)Steps to reproduce
nishiand commit the candidate你是.你是is emitted viaonDataafter the spaces. Actual: nothing is emitted — the commit is entirely swallowed. With 1 space,是is emitted instead of你是(first char dropped); the number of dropped characters always equals the number of pre-existing characters.Root cause (traced at
b1aee19)compositionstartrecords_compositionPosition.start = selectionStart— with 3 pre-existing spaces,start = 3.selectionStartis read exactly once in the whole file;compositionupdateonly re-readsselectionEnd, sostartis never corrected afterwards.Real event trace (DevTools instrumentation, condensed;
ta= textarea value,sel/selEnd= selection):In
_finalizeComposition'swaitForPropagationbranch,_isComposingis alreadyfalse, so theelsebranch runs:currentCompositionSuffixis''(nothing followed the caret at compositionstart),valueEnd = value.length = 2, andvalue.substring(3, Math.max(3, 2)) === ""→ nothing is emitted. With shorter pre-text the substring drops exactly that many leading characters.Relationship to existing issues
_compositionSuffix, screenReaderMode caret-in-middle). There is no analogous guard for leading text absorbed by a whole-value replacement, which is what happens here._handleAnyTextareaChangesduplicates or drops characters on key rollover when IME reports keyCode=229 (companion defect to #5887) #6045 are WKWebView/macOS event-delivery issues inCoreBrowserTerminal, unrelated code path.Regression-test-shaped repro (no IME hardware needed)
Following the harness pattern of the existing #5698 test in
src/browser/input/CompositionHelper.test.ts(sketch — encodes the traced event shape above):Proposed fix
Stop deriving the committed string from offset-slicing the textarea in the
compositionend-driven finalize path, and instead record thecompositionendevent'sdata(the DOM's authoritative committed string) and emit that in thewaitForPropagation === truebranch. The keydown-driven early-finalize path (waitForPropagation === false) keeps the existing slice, since nocompositionenddata exists yet there.This is immune to any replacement geometry (offsets no longer matter), and sidesteps the trailing-suffix special case for the commit path as well. We ship this fix in a downstream Electron terminal app (as a patch on
6.1.0-beta.220) and have verified it with real Microsoft Pinyin hand-testing plus a synthetic regression suite covering single/multi-char commits, double-emission guards, English-candidate commits, and this replacement scenario.Happy to open a PR with the fix and the regression test above.