From 471e62906d7f36cc3b66a0d1c1dd54d9fa1000e3 Mon Sep 17 00:00:00 2001 From: hyeonjin Date: Fri, 1 May 2026 14:26:24 +0900 Subject: [PATCH] fix: serialize IME composition with subsequent keystrokes (#3164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/app/view/term/term-model.ts | 3 +++ frontend/app/view/term/termwrap.ts | 29 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..fe6e90f811 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -697,6 +697,9 @@ export class TermViewModel implements ViewModel { } handleTerminalKeydown(event: KeyboardEvent): boolean { + if (event.isComposing || event.keyCode === 229) { + return false; + } const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (waveEvent.type != "keydown") { return true; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d10b600459..57b41efe2a 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -111,6 +111,8 @@ export class TermWrap { lastPasteData: string = ""; lastPasteTime: number = 0; + pendingImeDedup: string[] = []; + // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; @@ -383,6 +385,27 @@ export class TermWrap { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); + const ta = this.terminal.textarea; + if (ta) { + const onCompEnd = (e: CompositionEvent) => { + const compHelper = (this.terminal as any)._core?._inputHandler?._compositionHelper; + if (compHelper && "_isSendingComposition" in compHelper) { + compHelper._isSendingComposition = false; + } + if (!e.data) { + return; + } + this.pendingImeDedup.push(e.data); + this.sendDataHandler?.(e.data); + this.multiInputCallback?.(e.data); + }; + ta.addEventListener("compositionend", onCompEnd); + this.toDispose.push({ + dispose: () => { + ta.removeEventListener("compositionend", onCompEnd); + }, + }); + } this.toDispose.push( this.terminal.onSelectionChange( debounce(50, () => { @@ -467,7 +490,11 @@ export class TermWrap { if (!this.loaded) { return; } - + const dedupIdx = this.pendingImeDedup.indexOf(data); + if (dedupIdx !== -1) { + this.pendingImeDedup.splice(dedupIdx, 1); + return; + } this.sendDataHandler?.(data); this.multiInputCallback?.(data); }