fix: address pr-bug-scan validated finding from #7634

Post-compositionend candidate-key window no longer arms when the committing selector was consumed during composition, so a genuine Space/digit within 250ms reaches the PTY.
This commit is contained in:
orca-bug-scan-bot
2026-07-11 14:42:01 -07:00
parent 544bca5202
commit 33744ad330
3 changed files with 55 additions and 7 deletions
@@ -11,7 +11,10 @@ type TrackerHarness = {
tracker: TerminalImeCompositionTracker
element: HTMLElement
advance: (ms: number) => void
composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void
composition: (
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
data: string
) => void
input: (inputType: string) => void
blur: () => void
}
@@ -141,6 +144,20 @@ describe('installTerminalImeCompositionTracker', () => {
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
})
it('does not arm the post-composition window when the committing selector was consumed during composition', () => {
// Regression: committing Chinese via a Space/digit candidate key (already
// suppressed during the live composition) armed the post-compositionend
// window, which then swallowed the user's very next genuine Space/digit
// typed within 250ms (e.g. "2024" -> "024", or a separator space lost).
const harness = installTracker()
harness.composition('compositionstart', '')
harness.composition('compositionupdate', '')
// The candidate selector was suppressed while the composition was live.
harness.tracker.noteCandidateSelectionDuringComposition()
harness.composition('compositionend', '你好')
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
})
it('drops the post-composition window once ordinary typing resumes', () => {
const harness = installTracker()
harness.composition('compositionstart', '')
@@ -6,6 +6,11 @@ export type TerminalImeCompositionTracker = IDisposable & {
* IME-owned: during a live composition, and briefly after compositionend to
* absorb the committing key's trailing press/release. */
isCandidateKeyGuardActive: () => boolean
/** Called by the key handler when a candidate-selection key (Space/digit) is
* suppressed while a composition is still live. The committing selector has
* then already been consumed, so the post-compositionend window must not arm
* and swallow the user's next genuine Space/digit typed within 250ms. */
noteCandidateSelectionDuringComposition: () => void
}
// Why: suppressed candidate keys are preventDefault-ed and fire no input
@@ -26,6 +31,14 @@ export function installTerminalImeCompositionTracker(
let lastCompositionEventAt: number | null = null
let compositionEndedAt: number | null = null
let sawEmptyCompositionUpdate = false
// Why: when the committing Space/digit is consumed during the live
// composition, no trailing selector arrives after compositionend, so arming
// the post-composition window would only swallow the user's next real key.
let candidateSelectionDuringComposition = false
const noteCandidateSelectionDuringComposition = (): void => {
candidateSelectionDuringComposition = true
}
const isActiveAt = (at: number): boolean =>
active &&
@@ -47,6 +60,7 @@ export function installTerminalImeCompositionTracker(
return {
isActive: () => active,
isCandidateKeyGuardActive,
noteCandidateSelectionDuringComposition,
dispose: () => undefined
}
}
@@ -56,6 +70,7 @@ export function installTerminalImeCompositionTracker(
lastCompositionEventAt = now()
compositionEndedAt = null
sawEmptyCompositionUpdate = false
candidateSelectionDuringComposition = false
}
const updateComposition = (event: Event): void => {
lastCompositionEventAt = now()
@@ -75,8 +90,13 @@ export function installTerminalImeCompositionTracker(
active = false
// Why: only Sogou/fcitx-style empty updates prove a trailing plain
// Space/digit is likely IME-owned; broad post-end guards drop real typing.
compositionEndedAt = sawEmptyCompositionUpdate ? now() : null
// If the committing selector was already consumed during the composition,
// no trailing selector follows, so arming the window would only swallow
// the user's next genuine Space/digit.
compositionEndedAt =
sawEmptyCompositionUpdate && !candidateSelectionDuringComposition ? now() : null
sawEmptyCompositionUpdate = false
candidateSelectionDuringComposition = false
}
const handleInput = (event: Event): void => {
if (event instanceof InputEvent && event.inputType === 'insertCompositionText') {
@@ -87,12 +107,14 @@ export function installTerminalImeCompositionTracker(
// the post-end window would swallow a legitimate Space/digit.
compositionEndedAt = null
sawEmptyCompositionUpdate = false
candidateSelectionDuringComposition = false
}
const markInactive = (): void => {
active = false
lastCompositionEventAt = null
compositionEndedAt = null
sawEmptyCompositionUpdate = false
candidateSelectionDuringComposition = false
}
terminalElement.addEventListener('compositionstart', markActive, true)
@@ -104,6 +126,7 @@ export function installTerminalImeCompositionTracker(
return {
isActive: () => isActiveAt(now()),
isCandidateKeyGuardActive,
noteCandidateSelectionDuringComposition,
dispose: () => {
terminalElement.removeEventListener('compositionstart', markActive, true)
terminalElement.removeEventListener('compositionupdate', updateComposition, true)
@@ -881,11 +881,12 @@ export function useTerminalPaneLifecycle({
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
pane.terminal.attachCustomKeyEventHandler((e) => {
const now = Date.now()
const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease(
e,
pendingTerminalImeCandidateKeyReleases,
now
)
const pendingCandidateReleaseGuardActive =
shouldApplyTerminalImePendingCandidateKeyRelease(
e,
pendingTerminalImeCandidateKeyReleases,
now
)
const imeKeyboardOptions = {
compositionActive: imeCompositionTracker.isActive(),
candidateKeyGuardActive:
@@ -908,6 +909,13 @@ export function useTerminalPaneLifecycle({
e,
now
)
if (imeKeyboardOptions.compositionActive) {
// Why: the committing Space/digit was consumed during the live
// composition, so the post-compositionend window must not also
// arm and swallow the user's next genuine Space/digit typed
// within 250ms.
imeCompositionTracker.noteCandidateSelectionDuringComposition()
}
}
return false
}