Fix duplicate and lost IME terminal commits (#9235)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-17 18:29:44 -07:00
committed by GitHub
co-authored by Orca
parent 2f5fc65ba4
commit 360b2423ea
4 changed files with 347 additions and 24 deletions
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -273,7 +273,8 @@
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch",
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch"
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch",
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch"
}
},
"reactDoctor": {
+26 -23
View File
@@ -17,6 +17,9 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
path: config/patches/node-pty@1.1.0.patch
@@ -42,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=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -202,25 +205,25 @@ importers:
version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.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)
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@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)
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@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)
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9580,39 +9583,39 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
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)':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function openTerminal(): {
emitted: string[]
terminal: Terminal
textarea: HTMLTextAreaElement
} {
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 startComposition(textarea: HTMLTextAreaElement, text: string): void {
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: text, bubbles: true }))
textarea.value = text
}
function dispatchKeypress(textarea: HTMLTextAreaElement, text: string): void {
const keypress = new KeyboardEvent('keypress', { key: text, bubbles: true })
// happy-dom omits Chromium's legacy charCode field that xterm still reads.
Object.defineProperty(keypress, 'charCode', { value: text.charCodeAt(0) })
textarea.dispatchEvent(keypress)
}
describe('xterm IME composition de-duplication', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('emits a post-composition IBus Hangul keypress only once', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
// Why: IBus clears at compositionend, then restores the same commit after
// xterm's keypress path has already emitted it.
textarea.value = ''
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
const compositionHelper = (
terminal as unknown as {
_core: {
_compositionHelper: {
_isSendingComposition: boolean
_pendingKeypressData: string
}
}
}
)._core._compositionHelper
expect(compositionHelper._isSendingComposition).toBe(true)
dispatchKeypress(textarea, '한')
expect(compositionHelper._pendingKeypressData).toBe('한')
expect(emitted).toEqual([])
textarea.value = '한'
textarea.dispatchEvent(
new InputEvent('input', { data: '한', inputType: 'insertText', bubbles: true })
)
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('preserves composition-first order when keypress overlaps its suffix', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '가한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('가한')
terminal.dispose()
})
it('emits unmatched keypress before propagated composition text', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'a', bubbles: true }))
textarea.value = 'a'
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('does not repeat keypress contained before propagated composition text', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(
new CompositionEvent('compositionupdate', { data: '한a', bubbles: true })
)
textarea.value = '한a'
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('merges multiple deferred keypresses with partial textarea overlap', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchKeypress(textarea, 'a')
dispatchKeypress(textarea, 'b')
textarea.value = '한a'
await nextEventLoop()
expect(emitted.join('')).toBe('한ab')
terminal.dispose()
})
it('reconciles buffered keypress before immediate keydown finalization', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.value = ''
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchKeypress(textarea, '한')
textarea.value = '한'
const keydown = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true })
Object.defineProperty(keydown, 'keyCode', { value: 65 })
textarea.dispatchEvent(keydown)
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
})