feat(ime): add shared Enter-ownership seams for CJK composition

The confirming Enter of a CJK composition arrives as two keydowns and the
orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13
before keyup, macOS delivers keyup first. A guard reading only isComposing or
keyCode 229 misses the redispatch, so surfaces submitted on a confirm.

Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared
ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering
18 CommandInput surfaces at one site.

A chorded Enter arms the carry but is never swallowed — the reverse would eat a
user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by
ime-enter-gesture-ownership-contract.test.ts.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-06 00:52:34 -07:00
co-authored by Orca
parent 22b4cec951
commit 12f9b04335
7 changed files with 635 additions and 2 deletions
@@ -0,0 +1,53 @@
import { act, fireEvent } from '@testing-library/react'
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function dispatchImplicitSubmit(input: HTMLInputElement, init: KeyboardEventInit): boolean {
const prevented = dispatchKey(input, 'keydown', init)
if (!prevented) {
const form = input.closest('form')
if (!form) {
throw new Error('missing implicit-submit form')
}
act(() => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })))
}
return prevented
}
export function dispatchRecordedImeImplicitSubmit(input: HTMLInputElement): boolean {
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchImplicitSubmit(input, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', code: 'Enter', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
return prevented
}
export function dispatchOrdinaryImplicitSubmit(input: HTMLInputElement): boolean {
return dispatchImplicitSubmit(input, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
}
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { dispatchOrdinaryImplicitSubmit } from './ime-enter-guarded-form.test-events'
import { ImeEnterGuardedForm } from './ime-enter-guarded-form'
function dispatchKey(input: HTMLInputElement, init: KeyboardEventInit): boolean {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
afterEach(cleanup)
describe('form-level Enter default prevention', () => {
it('allows browser implicit submission without a bubbled veto', async () => {
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
const user = userEvent.setup()
render(
<form onSubmit={onSubmit}>
<input aria-label="unguarded" />
</form>
)
await user.click(screen.getByLabelText('unguarded'))
await user.keyboard('{Enter}')
expect(onSubmit).toHaveBeenCalledOnce()
})
it('vetoes browser implicit submission from the bubbled form keydown', async () => {
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
let bubbledCurrentTarget: EventTarget | null = null
const onKeyDown = vi.fn((event: React.KeyboardEvent) => {
bubbledCurrentTarget = event.currentTarget
event.preventDefault()
})
const user = userEvent.setup()
render(
<form onKeyDown={onKeyDown} onSubmit={onSubmit}>
<input aria-label="guarded" />
</form>
)
await user.click(screen.getByLabelText('guarded'))
await user.keyboard('{Enter}')
expect(onKeyDown).toHaveBeenCalledOnce()
expect(bubbledCurrentTarget).toBeInstanceOf(HTMLFormElement)
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('ImeEnterGuardedForm field ownership', () => {
it('resets the carry when focus moves between fields', () => {
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
render(
<ImeEnterGuardedForm onSubmit={onSubmit}>
<input aria-label="first" />
<input aria-label="second" />
</ImeEnterGuardedForm>
)
const first = screen.getByLabelText('first') as HTMLInputElement
const second = screen.getByLabelText('second') as HTMLInputElement
fireEvent.focus(first)
fireEvent.compositionStart(first)
expect(
dispatchKey(first, {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
).toBe(true)
fireEvent.compositionEnd(first, { data: '가' })
fireEvent.blur(first)
fireEvent.focus(second)
expect(dispatchOrdinaryImplicitSubmit(second)).toBe(false)
expect(onSubmit).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,42 @@
import type { ComponentProps } from 'react'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
export function ImeEnterGuardedForm({
onBlur,
onCompositionEnd,
onCompositionStart,
onKeyDown,
onKeyUp,
...props
}: ComponentProps<'form'>): React.JSX.Element {
const imeEnter = useImeEnterGestureOwnership()
return (
<form
{...props}
onCompositionStart={(event) => {
imeEnter.setComposing(true)
onCompositionStart?.(event)
}}
onCompositionEnd={(event) => {
imeEnter.setComposing(false)
onCompositionEnd?.(event)
}}
onKeyDown={(event) => {
if (imeEnter.ownsKeyDown(event)) {
event.preventDefault()
return
}
onKeyDown?.(event)
}}
onKeyUp={(event) => {
imeEnter.onKeyUp(event)
onKeyUp?.(event)
}}
onBlur={(event) => {
imeEnter.reset()
onBlur?.(event)
}}
/>
)
}
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Command, CommandInput, CommandItem, CommandList } from './command'
// cmdk puts its Enter->select dispatch on the Command root and guards it with
// only `isComposing || keyCode === 229`. macOS redispatches the Enter that
// confirms a CJK composition as an unmarked Enter/13, which that guard lets
// through. These pin the veto our root handler adds — the guard itself lives in
// a dependency and cannot be fixed at the source.
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function render(onSelect: () => void): HTMLInputElement {
act(() => {
root.render(
<Command shouldFilter={false}>
<CommandInput placeholder="Search templates..." />
<CommandList>
<CommandItem value="가나다 template" onSelect={onSelect}>
template
</CommandItem>
</CommandList>
</Command>
)
})
return container.querySelector('input')!
}
function key(input: HTMLInputElement, type: 'keydown' | 'keyup', init: KeyboardEventInit): void {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
Object.defineProperty(event, 'isComposing', { value: init.isComposing === true })
act(() => input.dispatchEvent(event))
}
function composition(
input: HTMLInputElement,
type: 'compositionstart' | 'compositionend',
data = ''
) {
act(() => input.dispatchEvent(new CompositionEvent(type, { bubbles: true, data })))
}
// The carry expires on the NEXT FRAME, never synchronously — macOS delivers keyup
// before its unmarked redispatch. A human pressing a later deliberate Enter is many
// frames away, so advance one.
function advanceFrame(run: () => void): void {
let frame: FrameRequestCallback | undefined
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
frame = cb
return 1
})
run()
act(() => frame?.(0))
raf.mockRestore()
}
describe('Command IME Enter ownership', () => {
it('selects the highlighted item on an ordinary Enter', () => {
const onSelect = vi.fn()
const input = render(onSelect)
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(onSelect).toHaveBeenCalledOnce()
})
// Recorded macOS 2-Set Korean shape: the confirming Enter arrives twice, and the
// second one is unmarked. Before the veto this created a file the user never asked
// for while their search text was still mid-word.
it('does not select on the unmarked Enter that macOS redispatches after compositionend', () => {
const onSelect = vi.fn()
const input = render(onSelect)
composition(input, 'compositionstart')
key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true })
composition(input, 'compositionend', '가')
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(onSelect).not.toHaveBeenCalled()
})
it('selects on the deliberate Enter that follows a confirmed composition', () => {
const onSelect = vi.fn()
const input = render(onSelect)
composition(input, 'compositionstart')
key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true })
composition(input, 'compositionend', '가')
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
advanceFrame(() => key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 }))
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
expect(onSelect).toHaveBeenCalledOnce()
})
// Windows/Linux redispatch the unmarked Enter BEFORE keyup, so the carry must not
// depend on the macOS ordering.
it('does not select when the redispatch precedes keyup', () => {
const onSelect = vi.fn()
const input = render(onSelect)
composition(input, 'compositionstart')
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 229, isComposing: true })
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
composition(input, 'compositionend', '가')
key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(onSelect).not.toHaveBeenCalled()
})
it('still forwards a consumer onKeyDown for keys the IME does not own', () => {
const onKeyDown = vi.fn()
act(() => {
root.render(
<Command shouldFilter={false} onKeyDown={onKeyDown}>
<CommandInput />
<CommandList>
<CommandItem value="a">a</CommandItem>
</CommandList>
</Command>
)
})
const input = container.querySelector('input')!
key(input, 'keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 })
expect(onKeyDown).toHaveBeenCalledOnce()
})
})
+46 -1
View File
@@ -5,12 +5,57 @@ import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import {
isImeOwnedKeyboardEvent,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
import { cn } from '@/lib/utils'
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
/**
* Why: cmdk owns the Enter->select dispatch on this root div, guarded by only
* `isComposing || keyCode === 229`. macOS redispatches the Enter that merely
* confirms a CJK composition as an unmarked `Enter`/13, which sails past that
* guard and selects whatever row is highlighted while the user is still
* mid-word. The guard lives in a dependency, but cmdk calls this handler before
* its own switch and skips on `defaultPrevented`, so vetoing here is the single
* seam that covers every CommandInput surface.
*/
function Command({
className,
onCompositionEnd,
onCompositionStart,
onKeyDown,
onKeyUp,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
const imeEnter = useImeEnterGestureOwnership()
return (
<CommandPrimitive
data-slot="command"
onKeyDown={(event) => {
if (imeEnter.ownsKeyDown(event)) {
// The hook cancels the carried redispatch itself; only an Enter marked
// by composition state alone still needs the veto cmdk would miss.
if (!isImeOwnedKeyboardEvent(event)) {
event.preventDefault()
}
return
}
onKeyDown?.(event)
}}
onKeyUp={(event) => {
imeEnter.onKeyUp(event)
onKeyUp?.(event)
}}
onCompositionStart={(event) => {
imeEnter.setComposing(true)
onCompositionStart?.(event)
}}
onCompositionEnd={(event) => {
imeEnter.setComposing(false)
onCompositionEnd?.(event)
}}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
@@ -1,4 +1,4 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
type ImeKeyboardEvent = {
isComposing?: boolean
@@ -44,6 +44,99 @@ export function isMarkedImeOwnedShortcutEvent(event: object): boolean {
return (event as { [IME_OWNED_SHORTCUT_EVENT]?: boolean })[IME_OWNED_SHORTCUT_EVENT] === true
}
type ImeEnterGestureEvent = Pick<
ReactKeyboardEvent,
'key' | 'keyCode' | 'nativeEvent' | 'preventDefault' | 'shiftKey'
> & { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }
/**
* Why: the confirming Enter of a CJK composition arrives as two keydowns, and the
* two orderings differ by platform. Windows/Linux redispatch the unmarked
* `Enter`/13 *before* keyup; macOS delivers keyup first and redispatches after.
* A token that expires synchronously on keyup therefore regresses macOS, so the
* carry survives until the next animation frame. Identity-scoped so an older
* gesture's expiry cannot clear a newer one.
*/
export function useImeEnterGestureOwnership(): {
isComposing: () => boolean
ownsKeyDown: (event: ImeEnterGestureEvent) => boolean
onKeyUp: (event: ImeEnterGestureEvent) => void
reset: () => void
setComposing: (active: boolean) => void
} {
const stateRef = useRef<{ composing: boolean; pendingEnter: object | null }>({
composing: false,
pendingEnter: null
})
return useMemo(() => {
const reset = (): void => {
stateRef.current = { composing: false, pendingEnter: null }
}
// Shift+Enter is a newline, never a submit — it must never be owned or swallowed.
const isPlainEnter = (event: ImeEnterGestureEvent): boolean =>
event.key === 'Enter' && event.keyCode === 13 && !event.shiftKey
// The redispatched Enter of a confirm carries no modifiers, so a chorded one is the
// user's own submit aimed past the IME. It must still ARM, and must never be swallowed.
const hasChordModifier = (event: ImeEnterGestureEvent): boolean =>
Boolean(event.altKey || event.ctrlKey || event.metaKey)
return {
isComposing: () => stateRef.current.composing,
ownsKeyDown: (event: ImeEnterGestureEvent): boolean => {
const markedEnter =
(event.nativeEvent.isComposing || stateRef.current.composing) &&
(isPlainEnter(event) ||
(event.key === 'Enter' && event.keyCode === 229) ||
(event.key === 'Process' && event.keyCode === 229))
if (markedEnter) {
stateRef.current.pendingEnter = {}
return true
}
if (
stateRef.current.pendingEnter &&
isPlainEnter(event) &&
!event.nativeEvent.isComposing
) {
// The gesture resolves either way, so the carry is spent either way; only a bare
// Enter is also swallowed, because a chorded one is the user's own submit.
stateRef.current.pendingEnter = null
if (hasChordModifier(event)) {
return false
}
event.preventDefault()
return true
}
return false
},
onKeyUp: (event: ImeEnterGestureEvent): void => {
// A Process/229 keyup means the IME finished without redispatching, so the
// gesture is over immediately.
if (event.key === 'Process' && event.keyCode === 229) {
stateRef.current.pendingEnter = null
return
}
// Every other keyup expires on the NEXT FRAME, never synchronously. Enter/13
// because macOS delivers keyup before the unmarked redispatch; anything else
// because IMEs reporting Process/229 on every key (Pinyin candidate selection)
// release a non-Enter key, and a Process-only clear left the carry armed and ate
// the user's next real Enter.
const pendingEnter = stateRef.current.pendingEnter
if (pendingEnter) {
requestAnimationFrame(() => {
if (stateRef.current.pendingEnter === pendingEnter) {
stateRef.current.pendingEnter = null
}
})
}
},
reset,
setComposing: (active: boolean) => {
stateRef.current.composing = active
}
}
}, [])
}
/**
* Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that
* only confirms a conversion candidate. Rename/title inputs that commit on
@@ -0,0 +1,167 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useImeEnterGestureOwnership } from './ime-composition-keyboard-event'
/**
* The ownership contract for the confirming Enter of a CJK composition. Every case below is
* load-bearing; two of them describe bugs that were live on the same day, in opposite
* directions, and both are easy to reintroduce while "simplifying" the consume branch:
*
* - Drop `!hasChordModifier` from the consume branch and a chorded confirm is SWALLOWED —
* the user's Ctrl/Cmd+Enter submit silently does nothing.
* - Clear the carry only on the bare path and the chorded confirm leaves it ARMED —
* the user's next Enter is eaten instead.
*
* The carry must be spent on both paths; only a bare Enter is also consumed.
*/
type Chord = { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
type TestKeyEvent = ReactKeyboardEvent & { prevented: boolean }
function enter(
opts: { key?: string; keyCode?: number; isComposing?: boolean } & Chord = {}
): TestKeyEvent {
return {
key: opts.key ?? 'Enter',
keyCode: opts.keyCode ?? 13,
altKey: opts.altKey ?? false,
ctrlKey: opts.ctrlKey ?? false,
metaKey: opts.metaKey ?? false,
shiftKey: opts.shiftKey ?? false,
nativeEvent: { isComposing: opts.isComposing ?? false },
prevented: false,
preventDefault() {
;(this as { prevented: boolean }).prevented = true
}
} as unknown as TestKeyEvent
}
function ownership() {
return renderHook(() => useImeEnterGestureOwnership()).result
}
/** Windows/Linux ordering: the IME redispatches the unmarked Enter before any keyup. */
function confirmGesture(result: ReturnType<typeof ownership>, redispatch: TestKeyEvent): boolean {
result.current.setComposing(true)
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
result.current.setComposing(false)
return result.current.ownsKeyDown(redispatch)
}
const CHORDS: Chord[] = [{ ctrlKey: true }, { altKey: true }, { metaKey: true }]
describe('IME Enter gesture ownership — the four behaviours', () => {
it('blocks the unmarked Enter redispatched after compositionend', () => {
const result = ownership()
const redispatch = enter()
expect(confirmGesture(result, redispatch)).toBe(true)
expect(redispatch.prevented).toBe(true)
})
it('blocks a chord pressed during composition, so the preedit survives', () => {
for (const chord of CHORDS) {
const result = ownership()
result.current.setComposing(true)
expect(result.current.ownsKeyDown(enter({ isComposing: true, ...chord }))).toBe(true)
}
})
it('submits when a modifier is held through the confirm', () => {
for (const chord of CHORDS) {
const result = ownership()
const redispatch = enter(chord)
expect(confirmGesture(result, redispatch)).toBe(false)
expect(redispatch.prevented).toBe(false)
}
})
it('submits an ordinary Enter with no composition in flight', () => {
const result = ownership()
const ordinary = enter()
expect(result.current.ownsKeyDown(ordinary)).toBe(false)
expect(ordinary.prevented).toBe(false)
})
})
describe('IME Enter gesture ownership — the carry is spent on both paths', () => {
it('does not eat the next Enter after a chorded confirm passed through', () => {
const result = ownership()
expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false)
const next = enter()
expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({
owned: false,
prevented: false
})
})
it('does not eat the next Enter even within the chord keyup frame', () => {
const result = ownership()
expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false)
// The chord's own keyup only schedules the next-frame expiry, so an Enter landing
// before that frame turns must still reach the app.
result.current.onKeyUp(enter({ ctrlKey: true }))
const next = enter()
expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({
owned: false,
prevented: false
})
})
it('spends the carry on a bare confirm too, so the following Enter is free', () => {
const result = ownership()
expect(confirmGesture(result, enter())).toBe(true)
expect(result.current.ownsKeyDown(enter())).toBe(false)
})
})
describe('IME Enter gesture ownership — Shift+Enter is always a newline', () => {
it('never owns Shift+Enter, composing or on the redispatch', () => {
const result = ownership()
result.current.setComposing(true)
expect(result.current.ownsKeyDown(enter({ isComposing: true, shiftKey: true }))).toBe(false)
result.current.setComposing(false)
const redispatch = enter({ shiftKey: true })
expect(result.current.ownsKeyDown(redispatch)).toBe(false)
expect(redispatch.prevented).toBe(false)
})
it('never owns Shift+Enter even while a real confirm is armed', () => {
const result = ownership()
expect(confirmGesture(result, enter({ shiftKey: true }))).toBe(false)
})
})
describe('IME Enter gesture ownership — expiry timing', () => {
it('outlives a keyup delivered before the redispatch, as macOS does', () => {
const result = ownership()
result.current.setComposing(true)
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
result.current.setComposing(false)
// A synchronous clear here sends the composed text one keystroke early on macOS.
result.current.onKeyUp(enter())
const redispatch = enter()
expect(result.current.ownsKeyDown(redispatch)).toBe(true)
expect(redispatch.prevented).toBe(true)
})
it('expires on the next frame rather than synchronously', async () => {
const result = ownership()
result.current.setComposing(true)
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
result.current.setComposing(false)
result.current.onKeyUp(enter())
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
expect(result.current.ownsKeyDown(enter())).toBe(false)
})
it('clears immediately on a Process/229 keyup, which means the IME finished', () => {
const result = ownership()
result.current.setComposing(true)
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
result.current.setComposing(false)
result.current.onKeyUp(enter({ key: 'Process', keyCode: 229 }))
expect(result.current.ownsKeyDown(enter())).toBe(false)
})
})