fix(terminal): deliver an IME commit the deferred textarea diff missed

Picking a single Chinese character from the candidate window with a
number key loses it. The character flashes and disappears. Picking the
same candidate with the mouse works, and picking multi-character words
with number keys works.

Two paths can deliver an IME commit, and this falls between them. A
keydown the input method consumed routes into a setTimeout(0) diff of
the helper textarea, and that diff is what normally delivers the commit;
xterm's _keyDownSeen guard exists to defer to it. When the commit
arrives after that timer has already run, neither path delivers. Mouse
selection works because no key is down, and a real composition session
works because it takes a different path entirely. That narrows it to an
input method whose commit round-trips asynchronously and which shows no
in-application preedit.

Track that a consumed keydown still owes its commit, and deliver only
when the diff did not. The upstream guard and its single read site are
untouched, which is what keeps the duplicate-commit behaviour it was
added for sealed.

Not doing the obvious repairs deliberately: clearing the flag, skipping
it for keyCode 229, or setting it after the composition short-circuit
each unblock the input path without retiring the diff, and all three
were measured emitting the character twice.

The patch and the lockfile hash here are generated. Review
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch, which
is the hand-written source of the change; the shipped patch and both
minified bundles are the regenerator's output from the pinned upstream
build, so nothing in this change was hand-transcribed into a bundle.

Refs xtermjs/xterm.js#6036
Closes #12099
This commit is contained in:
Neil
2026-08-17 21:49:12 -07:00
parent 19ba83d496
commit dfaa6fb1db
4 changed files with 353 additions and 59 deletions
File diff suppressed because one or more lines are too long
@@ -88,7 +88,7 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3ef59ed3b3 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..808c710356fe026471467e35ad776af8d94e8797 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -12,6 +12,28 @@ interface IPosition {
@@ -136,7 +136,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
/**
* The position within the input textarea's value of the current composition.
@@ -36,22 +67,57 @@ export class CompositionHelper {
@@ -36,22 +67,63 @@ export class CompositionHelper {
*/
private _compositionSuffix: string;
@@ -172,6 +172,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
*/
private _textareaChangeTimer?: number;
+ /**
+ * Whether the IME consumed the last keydown and still owes the commit it produced. Nothing is
+ * forwarded for such a keydown, so the commit is claimed by whichever observes it first.
+ */
+ private _imeKeydownAwaitingCommit: boolean;
+
+ /**
+ * Identifies the composition transaction that owns deferred work.
+ */
@@ -200,7 +206,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
constructor(
private readonly _textarea: HTMLTextAreaElement,
private readonly _compositionView: HTMLElement,
@@ -61,27 +127,58 @@ export class CompositionHelper {
@@ -61,27 +133,61 @@ export class CompositionHelper {
@IRenderService private readonly _renderService: IRenderService
) {
this._isComposing = false;
@@ -216,6 +222,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
+ this._compositionHasObservedProgress = false;
+ this._compositionTransactionId = 0;
+ this._compositionTimers = new Set();
+ this._imeKeydownAwaitingCommit = false;
}
/**
@@ -242,6 +249,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
+ this._compositionStartValue = this._textarea.value;
+ this._compositionStartSelection = { start, end };
+ this._compositionHasObservedProgress = false;
+ // A real session owns everything it commits, so no keydown is left owing one.
+ this._imeKeydownAwaitingCommit = false;
+ if (this._pendingComposition) {
+ this._pendingComposition.nextCompositionStart = this._compositionPosition.start;
+ }
@@ -262,7 +271,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
}
/**
@@ -89,22 +186,90 @@ export class CompositionHelper {
@@ -89,22 +195,90 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
@@ -362,7 +371,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
}
/**
@@ -113,7 +278,19 @@ export class CompositionHelper {
@@ -113,7 +287,19 @@ export class CompositionHelper {
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
@@ -383,7 +392,18 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
if (ev.keyCode === 20 || ev.keyCode === 229) {
// 20 is CapsLock, 229 is Enter
// Continue composing if the keyCode is the "composition character"
@@ -138,6 +315,54 @@ export class CompositionHelper {
@@ -128,6 +314,10 @@ export class CompositionHelper {
this._finalizeComposition(false);
}
+ // Nothing is forwarded for a keydown the IME consumed, so it is left owing its commit; any
+ // other keydown either forwards its own text or produces none, and clears the debt.
+ this._imeKeydownAwaitingCommit = ev.keyCode === 229;
+
if (ev.keyCode === 229) {
// If the "composition character" is used but gets to this point it means a non-composition
// character (eg. numbers and punctuation) was pressed when the IME was active.
@@ -138,6 +328,74 @@ export class CompositionHelper {
return true;
}
@@ -416,7 +436,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
+ }
+ const pending = this._pendingComposition;
+ if (!pending) {
+ return false;
+ return this._claimImeKeydownCommit(text);
+ }
+ if (pending.expectsPostCompositionInput) {
+ pending.inputData += text;
@@ -434,11 +454,31 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
+ }
+ return true;
+ }
+
+ /**
+ * Settles the commit a keydown the IME consumed still owes. That commit normally arrives as the
+ * deferred textarea diff, but an asynchronous IME can deliver it after the diff has already run
+ * and found the textarea unchanged, and with the key still down the terminal drops the input
+ * event instead (#12099). Whichever path sees the commit first sends it and cancels the other, so
+ * an IME that commits before the diff runs still sends once.
+ */
+ private _claimImeKeydownCommit(text: string): boolean {
+ if (!this._imeKeydownAwaitingCommit) {
+ return false;
+ }
+ this._imeKeydownAwaitingCommit = false;
+ if (this._textareaChangeTimer !== undefined) {
+ clearTimeout(this._textareaChangeTimer);
+ this._textareaChangeTimer = undefined;
+ }
+ this._coreService.triggerDataEvent(text, true);
+ return true;
+ }
+
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
@@ -146,23 +371,52 @@ export class CompositionHelper {
@@ -146,23 +404,52 @@ export class CompositionHelper {
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
@@ -502,7 +542,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -172,37 +426,310 @@ export class CompositionHelper {
@@ -172,37 +459,310 @@ export class CompositionHelper {
// - The last compositionupdate event's data property does not always accurately describe
// the character, a counter example being Korean where an ending consonsant can move to
// the following character if the following input is a vowel.
@@ -840,7 +880,17 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
/**
* Apply any changes made to the textarea after the current event chain is allowed to complete.
* This should be called when not currently composing but a keydown event with the "composition
@@ -236,6 +763,77 @@ export class CompositionHelper {
@@ -222,6 +782,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
+ if (newValue !== oldValue) {
+ this._imeKeydownAwaitingCommit = false;
+ }
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +799,77 @@ export class CompositionHelper {
}, 0);
}
@@ -918,7 +968,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +841,22 @@ export class CompositionHelper {
@@ -243,10 +877,22 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -942,7 +992,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,10 +875,17 @@ export class CompositionHelper {
@@ -265,10 +911,17 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
@@ -964,7 +1014,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..bbf8a510d9878b22f5c7b5bd0c6d7a3e
this._textarea.style.left = cursorLeft + 'px';
this._textarea.style.top = cursorTop + 'px';
// Ensure the text area is at least 1x1, otherwise certain IMEs may break
@@ -278,7 +895,8 @@ export class CompositionHelper {
@@ -278,7 +931,8 @@ export class CompositionHelper {
}
if (!dontRecurse) {
+24 -24
View File
@@ -18,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -45,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -208,25 +208,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9591,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=7fd86b33ead4457937d9ba9a406a8f0c26de4d69c4dc545c010c7c42ed6c0ba4)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,194 @@
// @vitest-environment happy-dom
/**
* An IME that shows candidates only in its own panel — ibus+Rime, fcitx5 with application preedit
* disabled — opens no composition session in the page, so a candidate picked with a number key
* arrives as a bare `insertText` with no compositionstart/update/end around it (#12099, and
* upstream xtermjs/xterm.js#6036).
*
* xterm forwards nothing for the keydown the IME consumed, yet `_keyDownSeen` still records it as a
* key being held, and `_inputEvent` admits a `composed` insertText only when no key is down — so
* the commit is dropped. The reporter's three cases differ only in that flag: mouse selection has
* no key down, a multi-character word runs through a real composition session, and a single
* character picked by number key is discarded.
*
* The commit for such a keydown is owed by exactly one of two paths — CompositionHelper's deferred
* textarea diff, or the input event itself — and which one observes it first depends on whether the
* IME commits synchronously. Both orderings are pinned here, because a fix that only unblocks the
* input event re-sends everything the diff already delivered.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const IME_KEYCODE = 229
type Rig = {
emitted: string[]
terminal: Terminal
textarea: HTMLTextAreaElement
}
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function openTerminal(): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
terminal.open(container)
const textarea = terminal.textarea
if (!textarea) {
throw new Error('xterm helper textarea was not created')
}
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
return { emitted, terminal, textarea }
}
function dispatchKey(
{ textarea }: Rig,
type: 'keydown' | 'keyup' | 'keypress',
init: { key: string; code: string; keyCode: number; charCode?: number }
): void {
const event = new KeyboardEvent(type, {
key: init.key,
code: init.code,
bubbles: true,
cancelable: true
})
// happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's IME paths read them.
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
Object.defineProperty(event, 'charCode', { value: init.charCode ?? 0 })
textarea.dispatchEvent(event)
}
/** The commit itself: the browser has already written it into the textarea when `input` fires. */
function dispatchCommit({ textarea }: Rig, data: string): void {
textarea.value = data
const event = new InputEvent('input', { bubbles: true })
Object.defineProperty(event, 'inputType', { value: 'insertText' })
Object.defineProperty(event, 'data', { value: data })
// Trusted user events are always composed; that is what makes the guard collapse to "no key down".
Object.defineProperty(event, 'composed', { value: true })
textarea.dispatchEvent(event)
}
function dispatchComposition(
{ textarea }: Rig,
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
data: string
): void {
if (type !== 'compositionstart') {
textarea.value = data
}
const event = new CompositionEvent(type, { bubbles: true })
Object.defineProperty(event, 'data', { value: data })
textarea.dispatchEvent(event)
}
/** Typing pinyin under a panel-only IME: every letter is consumed, nothing reaches the page. */
async function typeConsumedPinyin(rig: Rig, codes: string[]): Promise<void> {
for (const code of codes) {
dispatchKey(rig, 'keydown', { key: 'Process', code, keyCode: IME_KEYCODE })
await nextEventLoop()
dispatchKey(rig, 'keyup', { key: 'Process', code, keyCode: IME_KEYCODE })
await nextEventLoop()
}
}
describe('a candidate commit that arrives with no composition session', () => {
beforeEach(() => {
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('sends a candidate picked with the mouse, with no key down', async () => {
const rig = openTerminal()
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
dispatchCommit(rig, '好')
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('好')
})
it('sends a candidate committed by a real composition session exactly once', async () => {
const rig = openTerminal()
dispatchComposition(rig, 'compositionstart', '')
dispatchComposition(rig, 'compositionupdate', 'hao')
await nextEventLoop()
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
dispatchComposition(rig, 'compositionend', '好的')
await nextEventLoop()
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('好的')
})
// The reporter's failing case. ibus answers over DBus, so the commit lands after the deferred
// textarea diff has already run against an unchanged textarea — and the number key is still down.
it('sends a commit that arrives after the deferred diff while the consumed key is down', async () => {
const rig = openTerminal()
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
await nextEventLoop()
dispatchCommit(rig, '好')
await nextEventLoop()
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('好')
})
// The same gesture from an IME that commits synchronously. Here the deferred diff would also see
// the character, so admitting the input event without cancelling the diff sends 好好.
it('sends a commit that arrives before the deferred diff exactly once', async () => {
const rig = openTerminal()
await typeConsumedPinyin(rig, ['KeyH', 'KeyA', 'KeyO'])
dispatchKey(rig, 'keydown', { key: 'Process', code: 'Digit1', keyCode: IME_KEYCODE })
dispatchCommit(rig, '好')
await nextEventLoop()
dispatchKey(rig, 'keyup', { key: '1', code: 'Digit1', keyCode: 49 })
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('好')
})
// Regression guard for xtermjs/xterm.js#3533: Sogou on Linux fires compositionend *and*
// insertText for one commit, and the `!ev.composed` half of the guard exists to stop that
// doubling. Claiming a sessionless commit must not reopen it.
it('sends one commit when the IME fires both compositionend and insertText', async () => {
const rig = openTerminal()
dispatchComposition(rig, 'compositionstart', '')
dispatchComposition(rig, 'compositionupdate', 'hao')
await nextEventLoop()
dispatchComposition(rig, 'compositionend', '好')
dispatchCommit(rig, '好')
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('好')
})
// An IME-consumed keydown that never commits must not leave the claim armed for the next key.
// Upper-case letters take xterm's macOS caps-lock path, which forwards from keypress and then
// lets the input event through, so a stale claim would send the letter twice.
it('does not double an upper-case letter typed after a consumed keydown that never committed', async () => {
const rig = openTerminal()
await typeConsumedPinyin(rig, ['KeyH'])
dispatchKey(rig, 'keydown', { key: 'A', code: 'KeyA', keyCode: 65 })
dispatchKey(rig, 'keypress', { key: 'A', code: 'KeyA', keyCode: 65, charCode: 65 })
dispatchCommit(rig, 'A')
await nextEventLoop()
dispatchKey(rig, 'keyup', { key: 'A', code: 'KeyA', keyCode: 65 })
await nextEventLoop()
await nextEventLoop()
expect(rig.emitted.join('')).toBe('A')
})
})