Skip to content

Composition commit silently truncated when the IME replaces the whole textarea value (Windows TSF / Microsoft Pinyin) #6049

Description

@Dongshan-git

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

  1. On Windows with Microsoft Pinyin active, focus an xterm.js terminal.
  2. Type 3 spaces. (They are sent to the PTY normally and remain in the hidden helper textarea.)
  3. Type nishi and commit the candidate 你是.
  4. 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)

  1. 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.

  2. 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="你是"
    
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions