Skip to content

Commit 34aad10

Browse files
committed
fix: serialize IME composition with subsequent keystrokes (#3164)
xterm.js v6.x defers compositionend's final text via setTimeout(0). On fast IME input — an issue that can affect any CJK IME — a next-keydown can reach the PTY before the deferred IME text, producing reordered output. Two-pronged fix: 1. term-model.ts: drop keydown events while event.isComposing or keyCode === 229, so xterm doesn't forward the composition trigger key to the PTY during composition. 2. termwrap.ts: on compositionend, immediately send e.data via sendDataHandler and queue it in pendingImeDedup. handleTermData drops the matching string when xterm's deferred setTimeout(0) onData fires it again. As a best-effort additional guard, reset xterm's internal _isSendingComposition flag so the deferred callback becomes a no-op when the field name isn't minified. The dedup queue (string[]) supports overlapping composition cycles for very fast input. History: This is the same class of bug fixed in #2938, but the patches added there were intentionally removed in #3095 ("upgrade xterm.js to v6.0.0") under the assumption that xterm v6 would handle composition correctly. v6.0.0's CompositionHelper still defers final-text onData via setTimeout(0), so the race resurfaced and was reported as #3164 against v0.14.4. This PR re-fixes it in a way compatible with the v6 codebase, using a dedup queue rather than the v5-era patches. Closes #3164
1 parent efd450f commit 34aad10

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

frontend/app/view/term/term-model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,9 @@ export class TermViewModel implements ViewModel {
697697
}
698698

699699
handleTerminalKeydown(event: KeyboardEvent): boolean {
700+
if (event.isComposing || event.keyCode === 229) {
701+
return false;
702+
}
700703
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
701704
if (waveEvent.type != "keydown") {
702705
return true;

frontend/app/view/term/termwrap.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export class TermWrap {
111111
lastPasteData: string = "";
112112
lastPasteTime: number = 0;
113113

114+
pendingImeDedup: string[] = [];
115+
114116
// dev only (for debugging)
115117
recentWrites: { idx: number; data: string; ts: number }[] = [];
116118
recentWritesCounter: number = 0;
@@ -383,6 +385,27 @@ export class TermWrap {
383385
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
384386
const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace");
385387
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
388+
const ta = this.terminal.textarea;
389+
if (ta) {
390+
const onCompEnd = (e: CompositionEvent) => {
391+
const compHelper = (this.terminal as any)._core?._inputHandler?._compositionHelper;
392+
if (compHelper && "_isSendingComposition" in compHelper) {
393+
compHelper._isSendingComposition = false;
394+
}
395+
if (!e.data) {
396+
return;
397+
}
398+
this.pendingImeDedup.push(e.data);
399+
this.sendDataHandler?.(e.data);
400+
this.multiInputCallback?.(e.data);
401+
};
402+
ta.addEventListener("compositionend", onCompEnd);
403+
this.toDispose.push({
404+
dispose: () => {
405+
ta.removeEventListener("compositionend", onCompEnd);
406+
},
407+
});
408+
}
386409
this.toDispose.push(
387410
this.terminal.onSelectionChange(
388411
debounce(50, () => {
@@ -464,10 +487,14 @@ export class TermWrap {
464487
}
465488

466489
handleTermData(data: string) {
490+
const dedupIdx = this.pendingImeDedup.indexOf(data);
491+
if (dedupIdx !== -1) {
492+
this.pendingImeDedup.splice(dedupIdx, 1);
493+
return;
494+
}
467495
if (!this.loaded) {
468496
return;
469497
}
470-
471498
this.sendDataHandler?.(data);
472499
this.multiInputCallback?.(data);
473500
}

0 commit comments

Comments
 (0)