fix(terminal): clear the preedit overlay when an IME cancels a composition (#14758)

Backspacing over the last radical of a Cangjie composition empties the IME's
marked text without reaching compositionend, and the vendored xterm
CompositionHelper only dropped the overlay's `active` class there. The box
stayed painted with whatever glyph it last held (#11951).

Clear on the state rather than on the key, as native terminals do: an empty
`compositionupdate` now hides the overlay instead of only ever showing it, and
a key the IME swallows re-derives the preedit from the textarea once it settles
so a composition emptied with no composition event at all is cancelled too.
This commit is contained in:
Neil
2026-08-16 15:53:16 -07:00
committed by GitHub
parent 8e0dad8e21
commit 17ef6ccce6
3 changed files with 288 additions and 49 deletions
File diff suppressed because one or more lines are too long
+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: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
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=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,207 @@
// @vitest-environment happy-dom
/** Covers #11951: cancelling Cangjie preedit must clear and hide xterm's overlay. */
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const CELL_WIDTH_PX = 8
const CELL_HEIGHT_PX = 16
type RecordedEvent = {
data?: string
inputType?: string
isComposing?: boolean
key?: string
keyCode?: number
type: string
value?: string
}
/** Compose a single Cangjie radical: the preedit 尸 is showing and nothing is committed yet. */
const COMPOSE_RADICAL: RecordedEvent[] = [
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: false },
{ type: 'compositionstart', data: '' },
{ type: 'compositionupdate', data: '尸' },
{ type: 'beforeinput', data: '尸', isComposing: true },
{ type: 'input', data: '尸', isComposing: true, value: '尸' }
]
/** Backspace over the only radical: the IME drops the marked text and reports no composition end. */
const CANCEL_WITH_EMPTY_UPDATE: RecordedEvent[] = [
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
{ type: 'compositionupdate', data: '' },
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '' }
]
/** The same cancel from an IME that reports only the deletion, with no composition event at all. */
const CANCEL_WITH_INPUT_ONLY: RecordedEvent[] = [
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '' }
]
/** Backspace over one of several radicals: a preedit is still showing and must stay visible. */
const SHORTEN_PREEDIT: RecordedEvent[] = [
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
{ type: 'compositionupdate', data: '尸' },
{ type: 'input', isComposing: true, inputType: 'deleteContentBackward', value: '尸' }
]
const openTerminals: Terminal[] = []
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function buildEvent(recorded: RecordedEvent): Event {
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
const keyboard = new KeyboardEvent(recorded.type, {
bubbles: true,
cancelable: true,
isComposing: recorded.isComposing,
key: recorded.key ?? ''
})
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode })
return keyboard
}
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
const input = new InputEvent(recorded.type, {
bubbles: true,
isComposing: recorded.isComposing
})
// happy-dom drops these from InputEventInit; Chromium supplies them.
Object.defineProperty(input, 'inputType', {
value: recorded.inputType ?? 'insertCompositionText'
})
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
Object.defineProperty(input, 'composed', { value: true })
return input
}
const composition = new CompositionEvent(recorded.type, { bubbles: true })
Object.defineProperty(composition, 'data', { value: recorded.data ?? '' })
return composition
}
type Rig = {
compositionView: HTMLElement
emitted: string[]
replay: (events: RecordedEvent[]) => Promise<void>
}
function openTerminal(): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24 })
terminal.open(container)
const textarea = terminal.textarea
const compositionView = container.querySelector<HTMLElement>('.composition-view')
if (!textarea || !compositionView) {
throw new Error('xterm did not create the helper textarea and composition view')
}
openTerminals.push(terminal)
const cell = (
terminal as unknown as {
_core: {
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
}
}
)._core._renderService.dimensions.css.cell
cell.width = CELL_WIDTH_PX
cell.height = CELL_HEIGHT_PX
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
const replay = async (events: RecordedEvent[]): Promise<void> => {
for (const recorded of events) {
// Keys were physically separate, so each one began its own task.
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
await nextEventLoop()
}
if (recorded.value !== undefined) {
textarea.value = recorded.value
textarea.setSelectionRange(recorded.value.length, recorded.value.length)
}
textarea.dispatchEvent(buildEvent(recorded))
}
// The overlay is re-derived on a deferred task, exactly as the position update is.
await nextEventLoop()
await nextEventLoop()
}
return { compositionView, emitted, replay }
}
function displayedPreedit(view: HTMLElement): {
preedit: string
shown: boolean
} {
const shown = view.classList.contains('active')
const preedit = (view.textContent ?? '').replaceAll('', '')
return { preedit, shown }
}
describe('preedit visibility when the IME cancels a composition', () => {
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(async () => {
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
await nextEventLoop()
await nextEventLoop()
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('hides the preedit Backspace cancelled with an empty compositionupdate', async () => {
const rig = openTerminal()
await rig.replay(COMPOSE_RADICAL)
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
await rig.replay(CANCEL_WITH_EMPTY_UPDATE)
expect(displayedPreedit(rig.compositionView)).toEqual({
preedit: '',
shown: false
})
expect(rig.emitted).toEqual([])
})
it('hides the preedit Backspace cancelled without any composition event', async () => {
const rig = openTerminal()
await rig.replay(COMPOSE_RADICAL)
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
await rig.replay(CANCEL_WITH_INPUT_ONLY)
expect(displayedPreedit(rig.compositionView)).toEqual({
preedit: '',
shown: false
})
expect(rig.emitted).toEqual([])
})
it('keeps showing a preedit Backspace only shortened', async () => {
const rig = openTerminal()
await rig.replay([
...COMPOSE_RADICAL,
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
{ type: 'compositionupdate', data: '尸口' },
{ type: 'input', data: '尸口', isComposing: true, value: '尸口' }
])
await rig.replay(SHORTEN_PREEDIT)
expect(displayedPreedit(rig.compositionView)).toMatchObject({ preedit: '尸', shown: true })
expect(rig.emitted).toEqual([])
})
})