fix(chat): preserve browser IME composition ownership

This commit is contained in:
Neil
2026-08-04 08:53:55 -07:00
parent 47f59e423a
commit 585d01684a
2 changed files with 166 additions and 11 deletions
@@ -4,6 +4,7 @@ import type {
KeyboardEventHandler,
RefObject
} from 'react'
import { useLayoutEffect, useRef } from 'react'
import { Image as ImageIcon, ImageOff, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
@@ -97,6 +98,17 @@ export function NativeChatComposerField({
sessionOptionsSurface,
sessionOptionsSnapshot
}: NativeChatComposerFieldProps): React.JSX.Element {
const compositionActiveRef = useRef(false)
const pendingCompositionEnterRef = useRef(false)
useLayoutEffect(() => {
const textarea = textareaRef.current
if (!textarea || compositionActiveRef.current || textarea.value === draft) {
return
}
textarea.value = draft
}, [draft, textareaRef])
return (
<div className="shrink-0 bg-background">
{/* Extra bottom padding keeps the input box off the window rim. */}
@@ -164,13 +176,50 @@ export function NativeChatComposerField({
) : null}
<textarea
ref={textareaRef}
value={draft}
defaultValue={draft}
disabled={disabled}
rows={2}
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={(event) => {
const ownsCompositionEnter =
event.nativeEvent.isComposing &&
((event.key === 'Enter' && event.keyCode === 13) ||
(event.key === 'Process' && event.keyCode === 229))
if (ownsCompositionEnter) {
pendingCompositionEnterRef.current = true
return
}
if (
pendingCompositionEnterRef.current &&
event.key === 'Enter' &&
event.keyCode === 13 &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
return
}
onKeyDown(event)
}}
onKeyUp={(event) => {
if (
(event.key === 'Enter' && event.keyCode === 13) ||
(event.key === 'Process' && event.keyCode === 229)
) {
pendingCompositionEnterRef.current = false
}
}}
onBlur={() => {
compositionActiveRef.current = false
pendingCompositionEnterRef.current = false
}}
onCompositionStart={(event) => {
compositionActiveRef.current = true
onCompositionStart(event)
}}
onCompositionEnd={(event) => {
compositionActiveRef.current = false
onCompositionEnd(event)
}}
onPaste={onPaste}
onSelect={(e) => onTextareaSelect(e.currentTarget)}
aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'}
@@ -6,7 +6,7 @@
* has no layout engine, so real pixel growth is covered by app validation. */
import { createRef } from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({
@@ -22,12 +22,20 @@ vi.mock('./NativeChatAutocompleteMenus', () => ({
NativeChatPickerMenu: () => null
}))
import { NativeChatComposerField } from './NativeChatComposerField'
import {
NativeChatComposerField,
type NativeChatComposerFieldProps
} from './NativeChatComposerField'
afterEach(() => cleanup())
function renderField(draft: string): HTMLTextAreaElement {
render(
function composerField(
draft: string,
callbacks: Partial<
Pick<NativeChatComposerFieldProps, 'onDraftChange' | 'onKeyDown' | 'onCompositionEnd'>
> = {}
): React.JSX.Element {
return (
<NativeChatComposerField
textareaRef={createRef<HTMLTextAreaElement>()}
draft={draft}
@@ -44,11 +52,11 @@ function renderField(draft: string): HTMLTextAreaElement {
dictationDisabled={false}
isDictating={false}
isDictationHoldMode={false}
onDraftChange={vi.fn()}
onDraftChange={callbacks.onDraftChange ?? vi.fn()}
onTextareaSelect={vi.fn()}
onKeyDown={vi.fn()}
onKeyDown={callbacks.onKeyDown ?? vi.fn()}
onCompositionStart={vi.fn()}
onCompositionEnd={vi.fn()}
onCompositionEnd={callbacks.onCompositionEnd ?? vi.fn()}
onPaste={vi.fn()}
pickerListboxId="picker"
onChoosePickerItem={vi.fn()}
@@ -64,6 +72,10 @@ function renderField(draft: string): HTMLTextAreaElement {
sessionOptionsSnapshot={[]}
/>
)
}
function renderField(draft: string): HTMLTextAreaElement {
render(composerField(draft))
return screen.getByRole('textbox') as HTMLTextAreaElement
}
@@ -96,3 +108,97 @@ describe('native chat composer autogrow', () => {
expect(renderField('a\n'.repeat(6)).style.height).toBe('')
})
})
describe('native chat composer composition ownership', () => {
it('preserves browser-owned Korean preedit through stale streaming rerenders', () => {
const onCompositionEnd = vi.fn()
const view = render(composerField('', { onCompositionEnd }))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.compositionStart(textarea)
textarea.value = '가'
for (let index = 0; index < 120; index += 1) {
view.rerender(composerField(`stale stream ${index}`, { onCompositionEnd }))
expect(screen.getByRole('textbox')).toBe(textarea)
expect(textarea.value).toBe('가')
}
fireEvent.compositionEnd(textarea, { data: '가' })
expect(onCompositionEnd).toHaveBeenCalledOnce()
expect(onCompositionEnd.mock.calls[0][0].target.value).toBe('가')
})
it('applies draft rerenders while ordinary English input is idle', () => {
const view = render(composerField('abc'))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
view.rerender(composerField('ordinary'))
expect(screen.getByRole('textbox')).toBe(textarea)
expect(textarea.value).toBe('ordinary')
})
it('owns an unmarked Enter redispatch until the marked IME gesture keyup', () => {
const onKeyDown = vi.fn()
render(composerField('가', { onKeyDown }))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.compositionStart(textarea)
const markedResult = fireEvent.keyDown(textarea, {
key: 'Enter',
keyCode: 13,
isComposing: true
})
fireEvent.compositionEnd(textarea, { data: '가' })
const redispatchResult = fireEvent.keyDown(textarea, {
key: 'Enter',
keyCode: 13,
isComposing: false
})
expect(markedResult).toBe(true)
expect(redispatchResult).toBe(false)
expect(onKeyDown).not.toHaveBeenCalled()
fireEvent.keyUp(textarea, { key: 'Enter', keyCode: 13 })
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
expect(onKeyDown).toHaveBeenCalledOnce()
})
it('retains the Windows Process gesture through its unmarked Enter redispatch', () => {
const onKeyDown = vi.fn()
render(composerField('가', { onKeyDown }))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, {
key: 'Process',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(textarea, { data: '가' })
const redispatchResult = fireEvent.keyDown(textarea, {
key: 'Enter',
keyCode: 13,
isComposing: false
})
expect(redispatchResult).toBe(false)
expect(onKeyDown).not.toHaveBeenCalled()
fireEvent.keyUp(textarea, { key: 'Process', keyCode: 229 })
fireEvent.keyUp(textarea, { key: 'Enter', keyCode: 13 })
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
expect(onKeyDown).toHaveBeenCalledOnce()
})
it('passes ordinary English Enter through without an IME gesture', () => {
const onKeyDown = vi.fn()
render(composerField('abc', { onKeyDown }))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
expect(onKeyDown).toHaveBeenCalledOnce()
})
})