Files
orca/config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
T
Neil 9506039de7 fix(xterm): stop swallowing a keystroke typed after an IME commit
Type a Japanese segment, convert it, then press a key one macrotask later
and that key was lost. No modifier, nothing exotic — every user who keeps
typing straight after converting. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

handleCompositionInput discarded the payload unconditionally in the window
after the deferred send: _isSendingComposition stays true for one macrotask
after the timer cleared _pendingCompositionStart, and the branch substituted
'' for whatever arrived. The suppression is not itself wrong — it
de-duplicates IMEs that deliver their commit an event-loop turn late, which
terminal-stock-composition.test.ts pins. It just could not tell a duplicate
from new input, because the two events are identical apart from payload.

Now it compares against _sentComposition, the text the deferred send
actually emitted, and discards only a match. A flag-timing redesign was
measured first and rejected: it fixed this and duplicated on IBus, because
no timing change can separate events that differ only in content.

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and the lockfile hash are derived, not hand-written.

The commit-overlap pin's swallow arm now asserts the repaired contract —
the value its own comment already named as correct and as what stock
beta.287 emits. #11504's arm at :184 flips too; it never covered that
report, as its own prior note recorded, and the reporter's +149ms arm is
untouched and still asserting the defect. Provenance hashes in three test
docblocks are updated, since regenerating changes the patch hash and with it
the resolved install directory.
2026-08-06 09:45:37 -07:00

253 lines
11 KiB
Diff

diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..c16096341dadfd215a8086e13f7a0551025c0e77 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -735,6 +735,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
paste(data, this.textarea!, this.coreService, this.optionsService);
}
+ public override input(data: string, wasUserInput: boolean = true): void {
+ if (!wasUserInput || !this._compositionHelper?.handleCompositionInput(data, false)) super.input(data, wasUserInput);
+ }
+
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
this._customKeyEventHandler = customKeyEventHandler;
}
@@ -1029,6 +1033,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
// support reading out character input which can doubling up input characters
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
+ if (ev.data && ev.inputType === 'insertText' && this._compositionHelper?.handleCompositionInput(ev.data, true)) return true;
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
if (this._keyPressHandled) {
return false;
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
index 7b346459521dd77f9de25d43fba3c36a6f8459b2..4837a7c1a3eb36ec6f6223cbcec0f2dd6cc4e235 100644
--- a/src/browser/TestUtils.test.ts
+++ b/src/browser/TestUtils.test.ts
@@ -342,6 +342,9 @@ export class MockViewport implements IViewport {
}
export class MockCompositionHelper implements ICompositionHelper {
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ throw new Error('Method not implemented.');
+ }
public get isComposing(): boolean {
return false;
}
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81070941ed 100644
--- a/src/browser/Types.ts
+++ b/src/browser/Types.ts
@@ -39,6 +39,7 @@ export type LineData = CharData[];
export interface ICompositionHelper {
readonly isComposing: boolean;
+ handleCompositionInput(data: string, nativeCommit: boolean): boolean;
compositionstart(): void;
compositionupdate(ev: CompositionEvent): void;
compositionend(): void;
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d5c532339da18e0fecf6193ad579839bae269b83 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -5,7 +5,6 @@
import { IRenderService } from '../services/Services';
import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
-import { C0 } from '../../common/data/EscapeSequences';
interface IPosition {
start: number;
@@ -42,15 +41,9 @@ export class CompositionHelper {
*/
private _isSendingComposition: boolean;
- /**
- * Data already sent due to keydown event.
- */
- private _dataAlreadySent: string;
-
- /**
- * The pending textarea change timer, if any.
- */
- private _textareaChangeTimer?: number;
+ private _pendingCompositionStart?: number;
+ private _pendingInput = '';
+ private _sentComposition = '';
constructor(
private readonly _textarea: HTMLTextAreaElement,
@@ -64,7 +57,6 @@ export class CompositionHelper {
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
- this._dataAlreadySent = '';
}
/**
@@ -80,10 +72,27 @@ export class CompositionHelper {
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionView.textContent = '';
- this._dataAlreadySent = '';
this._compositionView.classList.add('active');
}
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ if (nativeCommit) {
+ if (!this._isSendingComposition) return false;
+ // Once the deferred send has run, an IME that delivers its commit a task late (IBus) repeats
+ // text we already sent; anything else is new input and must not be discarded with it.
+ const alreadySent = this._pendingCompositionStart === undefined && data === this._sentComposition;
+ const input = (alreadySent ? '' : data) + this._pendingInput;
+ this._pendingCompositionStart = undefined;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ this._isSendingComposition = false;
+ return true;
+ }
+ if (!this._isComposing && !this._isSendingComposition) return false;
+ this._pendingInput += data;
+ return true;
+ }
+
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
@@ -129,9 +138,6 @@ export class CompositionHelper {
}
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.
- this._handleAnyTextareaChanges();
return false;
}
@@ -153,7 +159,11 @@ export class CompositionHelper {
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
+ const start = this._pendingCompositionStart ?? this._compositionPosition.start;
+ const end = Math.max(start, this._textarea.selectionEnd ?? this._compositionPosition.end);
+ this._pendingCompositionStart = undefined;
+ const input = this._textarea.value.substring(start, end) + this._pendingInput;
+ this._pendingInput = '';
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
@@ -163,6 +173,7 @@ export class CompositionHelper {
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
+ this._pendingCompositionStart ??= currentCompositionPosition.start;
// 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
@@ -175,12 +186,10 @@ export class CompositionHelper {
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
- if (this._isSendingComposition) {
- this._isSendingComposition = false;
+ if (this._isSendingComposition && this._pendingCompositionStart !== undefined) {
+ currentCompositionPosition.start = this._pendingCompositionStart;
+ this._pendingCompositionStart = undefined;
let input;
- // Add length of data already sent due to keydown event,
- // otherwise input characters can be duplicated. (Issue #3191)
- currentCompositionPosition.start += this._dataAlreadySent.length;
if (this._isComposing) {
// Use the start position of the new composition to get the string
// if a new composition has started.
@@ -195,47 +204,22 @@ export class CompositionHelper {
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
- if (input.length > 0) {
- this._coreService.triggerDataEvent(input, true);
- }
+ this._sentComposition = input;
+ input += this._pendingInput;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ setTimeout(() => {
+ if (this._pendingCompositionStart === undefined) {
+ if (this._pendingInput.length > 0) this._coreService.triggerDataEvent(this._pendingInput, true);
+ this._pendingInput = '';
+ this._isSendingComposition = false;
+ }
+ }, 0);
}
}, 0);
}
}
- /**
- * 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
- * character" (229) is triggered, in order to allow non-composition text to be entered when an
- * IME is active.
- */
- private _handleAnyTextareaChanges(): void {
- if (this._textareaChangeTimer) {
- return;
- }
- const oldValue = this._textarea.value;
- this._textareaChangeTimer = window.setTimeout(() => {
- this._textareaChangeTimer = undefined;
- // Ignore if a composition has started since the timeout
- if (!this._isComposing) {
- const newValue = this._textarea.value;
-
- const diff = newValue.replace(oldValue, '');
-
- this._dataAlreadySent = diff;
-
- if (newValue.length > oldValue.length) {
- this._coreService.triggerDataEvent(diff, true);
- } else if (newValue.length < oldValue.length) {
- this._coreService.triggerDataEvent(`${C0.DEL}`, true);
- } else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
- this._coreService.triggerDataEvent(newValue, true);
- }
-
- }
- }, 0);
- }
-
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
return false;