fix(terminal): flush Hangul syllables before the next composition (#12278)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Hyunggyun Lyou <hg.lyou@miraeasset.com>
This commit is contained in:
OrcaWin 2026-08-03 02:45:08 -07:00 committed by GitHub
parent 6f7a30ac2e
commit f82d4100b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 242 additions and 69 deletions

File diff suppressed because one or more lines are too long

View File

@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e
hash: 037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@ -46,7 +46,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(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@ -206,25 +206,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=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@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=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@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=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@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=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
version: 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -9582,39 +9582,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
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=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=595d23676ffcec9a99a09e9fa2b8f12b9ae2934cf1ff376bcc68f4cf1ec8766e)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)': {}
abbrev@4.0.0: {}

View File

@ -0,0 +1,178 @@
// @vitest-environment happy-dom
import { createRequire } from 'node:module'
import { Terminal as EsmTerminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const requireFromHere = createRequire(import.meta.url)
const { Terminal: CjsTerminal } = requireFromHere('@xterm/xterm') as {
Terminal: typeof EsmTerminal
}
const openTerminals: EsmTerminal[] = []
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function openTerminal(TerminalType: typeof EsmTerminal): {
emitted: string[]
textarea: HTMLTextAreaElement
} {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new TerminalType()
openTerminals.push(terminal)
terminal.open(container)
if (!terminal.textarea) {
throw new Error('xterm textarea was not created')
}
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
return { emitted, textarea: terminal.textarea }
}
function composition(
textarea: HTMLTextAreaElement,
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
data?: string
): void {
const event = new CompositionEvent(type, { bubbles: true })
if (data !== undefined) {
Object.defineProperty(event, 'data', { value: data })
}
textarea.dispatchEvent(event)
}
function input(textarea: HTMLTextAreaElement, inputType: string, data?: string): void {
const event = new InputEvent('input', { bubbles: true, data: data ?? null, inputType })
Object.defineProperty(event, 'composed', { value: true })
textarea.dispatchEvent(event)
}
function keydown(textarea: HTMLTextAreaElement, key: string, code: string, keyCode: number): void {
const event = new KeyboardEvent('keydown', { bubbles: true, code, isComposing: true, key })
Object.defineProperty(event, 'keyCode', { value: keyCode })
textarea.dispatchEvent(event)
}
function composeSyllable(textarea: HTMLTextAreaElement, prefix: string, steps: string[]): void {
textarea.setSelectionRange(prefix.length, prefix.length)
composition(textarea, 'compositionstart')
for (const step of steps) {
composition(textarea, 'compositionupdate', step)
textarea.value = `${prefix}${step}`
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}
}
describe.each([
['ESM', EsmTerminal],
['CJS', CjsTerminal]
])('installed xterm Hangul syllable flush (%s)', (_format, TerminalType) => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.useRealTimers()
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('flushes syllable N before syllable N+1 finishes composing', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
composeSyllable(textarea, '', ['ㅎ', '하', '한'])
composition(textarea, 'compositionend', '한')
textarea.setSelectionRange(1, 1)
composition(textarea, 'compositionstart')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
})
it('preserves Korean final-consonant transfer', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
composeSyllable(textarea, '', ['ㅇ', '아', '앙'])
composition(textarea, 'compositionend', '앙')
textarea.value = '아아'
textarea.setSelectionRange(1, 1)
composition(textarea, 'compositionstart')
await nextEventLoop()
expect(emitted.join('')).toBe('아')
composition(textarea, 'compositionupdate', '아')
textarea.setSelectionRange(2, 2)
composition(textarea, 'compositionend', '아')
await nextEventLoop()
expect(emitted.join('')).toBe('아아')
expect(emitted.join('')).not.toContain('앙')
})
it('emits 한글 exactly once', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
composeSyllable(textarea, '', ['ㅎ', '하', '한'])
composition(textarea, 'compositionend', '한')
composeSyllable(textarea, '한', ['ㄱ', '그', '글'])
composition(textarea, 'compositionend', '글')
await nextEventLoop()
expect(emitted.join('')).toBe('한글')
})
it('does not double-send after an IBus insertText commit', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
textarea.setSelectionRange(0, 0)
composition(textarea, 'compositionstart')
keydown(textarea, 'Process', 'KeyG', 229)
composition(textarea, 'compositionupdate', '한')
textarea.value = '한'
input(textarea, 'insertCompositionText', '한')
composition(textarea, 'compositionupdate')
textarea.value = ''
input(textarea, 'deleteContentBackward')
composition(textarea, 'compositionend')
textarea.value = '한'
input(textarea, 'insertText', '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
textarea.setSelectionRange(1, 1)
composition(textarea, 'compositionstart')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
})
it('does not commit a composition deleted to empty', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
composeSyllable(textarea, '', ['ㅇ'])
composition(textarea, 'compositionupdate', '')
textarea.value = ''
textarea.setSelectionRange(0, 0)
composition(textarea, 'compositionend', '')
await nextEventLoop()
expect(emitted).toEqual([])
})
it('starts cleanly after deleting a composition to empty', async () => {
const { emitted, textarea } = openTerminal(TerminalType)
composeSyllable(textarea, '', ['ㅇ'])
composition(textarea, 'compositionupdate', '')
textarea.value = ''
textarea.setSelectionRange(0, 0)
composition(textarea, 'compositionend', '')
await nextEventLoop()
composeSyllable(textarea, '', ['ㅎ', '하', '한'])
composition(textarea, 'compositionend', '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
})
})