fix(mobile): fence native IME composition

This commit is contained in:
Brennan Benson
2026-08-02 16:38:16 -07:00
parent 5ddd38b600
commit 123d95c838
4 changed files with 148 additions and 17 deletions
@@ -38,6 +38,7 @@ function suppressReactTestRendererDeprecationWarning(): () => void {
}
type AccessoryInputCommitHarnessOptions = {
readonly compositionHandle?: string | null
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
@@ -58,6 +59,7 @@ type AccessoryInputCommitHarness = {
}
function createAccessoryInputCommitHarness({
compositionHandle = null,
heldText = '',
sentText = '',
pendingHandle = null,
@@ -70,6 +72,9 @@ function createAccessoryInputCommitHarness({
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputCompositionHandleRef: RefObject<string | null> = {
current: compositionHandle
}
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
@@ -95,6 +100,7 @@ function createAccessoryInputCommitHarness({
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputCompositionHandleRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
@@ -165,6 +171,16 @@ describe('terminal live accessory inactive input commit result', () => {
})
describe('terminal live accessory input commit hook', () => {
it('Given native marked text When an accessory key is pressed Then suppresses raw input', async () => {
const harness = createAccessoryInputCommitHarness({ compositionHandle: 'terminal-a' })
const result = await harness.commit({ bytes: '\x1b' })
expect(result).toEqual({ kind: 'suppress-raw' })
expect(harness.waitForPendingLiveInputFlush).not.toHaveBeenCalled()
expect(harness.sent).toEqual([])
})
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
@@ -26,6 +26,7 @@ type TerminalLiveAccessoryInputCommitOptions = {
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputCompositionHandleRef: RefObject<string | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
@@ -41,6 +42,7 @@ export function useTerminalLiveAccessoryInputCommit({
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputCompositionHandleRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
@@ -58,6 +60,9 @@ export function useTerminalLiveAccessoryInputCommit({
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
if (liveInputCompositionHandleRef.current === activeHandle) {
return { kind: 'suppress-raw' }
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
@@ -102,6 +107,7 @@ export function useTerminalLiveAccessoryInputCommit({
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputCompositionHandleRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
@@ -10,6 +10,8 @@ type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly sentByHandle: readonly { readonly bytes: string; readonly handle: string }[]
readonly setActiveHandle: (next: string) => void
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly setConnected: (next: boolean) => void
readonly setSendResult: (next: boolean) => void
@@ -35,23 +37,25 @@ function suppressReactTestRendererDeprecationWarning(): () => void {
function createTerminalLiveInputCommitHarness({
sendResult = true
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
let currentActiveHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: currentActiveHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandles = new Set(['terminal-a', 'terminal-b'])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
current: new Set([activeHandle])
current: new Set(liveInputTerminalHandles)
}
const sent: string[] = []
const sentByHandle: { bytes: string; handle: string }[] = []
let currentSendResult = sendResult
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
current: async (handle, bytes) => {
sent.push(bytes)
sentByHandle.push({ bytes, handle })
return currentSendResult
}
}
@@ -63,7 +67,7 @@ function createTerminalLiveInputCommitHarness({
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandle: currentActiveHandle,
activeHandleRef,
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabTypeRef,
@@ -91,8 +95,21 @@ function createTerminalLiveInputCommitHarness({
return {
captures,
handlers,
get handlers() {
if (!handlers) {
throw new Error('terminal live input hook is not mounted')
}
return handlers
},
sent,
sentByHandle,
setActiveHandle: (next: string): void => {
currentActiveHandle = next
activeHandleRef.current = next
act(() => {
renderer?.update(createElement(Harness))
})
},
setActiveSessionTabType: (next: string | undefined): void => {
currentActiveSessionTabType = next
// Ref and prop derive from the same activeSessionTab in the real route, so
@@ -241,6 +258,72 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['かな']))
})
it('Given marked text When the active terminal switches Then clears it without sending', () => {
const harness = createTerminalLiveInputCommitHarness()
changeLiveInput(harness.handlers, 'かな', true)
harness.setActiveHandle('terminal-b')
expect(harness.captures).toEqual(['かな', ''])
expect(harness.sent).toEqual([])
})
it('Given a switch during marked text When its final event arrives Then rejects it for the new terminal', async () => {
const harness = createTerminalLiveInputCommitHarness()
changeLiveInput(harness.handlers, 'かな', true)
harness.setActiveHandle('terminal-b')
changeLiveInput(harness.handlers, '仮名', false)
await Promise.resolve()
expect(harness.captures.at(-1)).toBe('')
expect(harness.sentByHandle).toEqual([])
})
it('Given active marked text When an external send requests a flush Then suppresses it until commit', async () => {
const harness = createTerminalLiveInputCommitHarness()
changeLiveInput(harness.handlers, 'かな', true)
await expect(
harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
).resolves.toBe(false)
expect(harness.sent).toEqual([])
changeLiveInput(harness.handlers, '仮名', false)
await vi.waitFor(() => expect(harness.sent).toEqual(['仮名']))
await expect(
harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
).resolves.toBe(true)
})
it('Given active marked text When an accessory key is pressed Then suppresses it until commit', async () => {
const harness = createTerminalLiveInputCommitHarness()
changeLiveInput(harness.handlers, 'かな', true)
await expect(
harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' })
).resolves.toEqual({ kind: 'suppress-raw' })
changeLiveInput(harness.handlers, '仮名', false)
await vi.waitFor(() => expect(harness.sent).toEqual(['仮名']))
await expect(
harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' })
).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given a switch clears marked text When controls target the new terminal Then allows them', async () => {
const harness = createTerminalLiveInputCommitHarness()
changeLiveInput(harness.handlers, 'かな', true)
harness.setActiveHandle('terminal-b')
await expect(
harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-b')
).resolves.toBe(true)
await expect(
harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' })
).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given active composition When native control events arrive Then keeps them inside the IME', () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, 'かな', true)
@@ -60,7 +60,8 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const liveInputComposingRef = useRef(false)
const liveInputCompositionHandleRef = useRef<string | null>(null)
const staleLiveInputCompositionHandleRef = useRef<string | null>(null)
const {
applyLiveInputMirror,
clearPendingLiveInputCommit: clearPendingLiveInputMirror,
@@ -79,7 +80,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
})
const clearPendingLiveInputCommit = useCallback(() => {
liveInputComposingRef.current = false
if (liveInputCompositionHandleRef.current) {
staleLiveInputCompositionHandleRef.current = liveInputCompositionHandleRef.current
liveInputCompositionHandleRef.current = null
}
clearPendingLiveInputMirror()
}, [clearPendingLiveInputMirror])
@@ -92,7 +96,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
const compositionHandle = liveInputCompositionHandleRef.current
const inputOwnerHandle = compositionHandle ?? pendingHandle
if (!inputOwnerHandle) {
return
}
// Why: a lagging mobile tab list briefly yields no active tab object; a
@@ -100,7 +106,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
// still block sends if the tab truly changed.
if (
!activeHandle ||
pendingHandle !== activeHandle ||
inputOwnerHandle !== activeHandle ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
@@ -110,6 +116,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
// Marked text belongs to the native IME until its final onChange event.
if (liveInputCompositionHandleRef.current === handle) {
return false
}
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
@@ -131,14 +141,29 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit()
return
}
if (isComposing === true) {
staleLiveInputCompositionHandleRef.current = null
liveInputCompositionHandleRef.current = activeHandle
setLiveInputCapture(text)
return
}
const compositionHandle = liveInputCompositionHandleRef.current
if (
(compositionHandle && compositionHandle !== activeHandle) ||
(!compositionHandle && staleLiveInputCompositionHandleRef.current)
) {
liveInputCompositionHandleRef.current = null
staleLiveInputCompositionHandleRef.current = null
clearPendingLiveInputMirror()
return
}
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
liveInputCompositionHandleRef.current = null
staleLiveInputCompositionHandleRef.current = null
setLiveInputCapture(text)
liveInputComposingRef.current = isComposing === true
if (liveInputComposingRef.current) {
return
}
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
},
[
@@ -155,7 +180,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (
!activeHandle ||
!liveInputTerminalHandles.has(activeHandle) ||
liveInputComposingRef.current
liveInputCompositionHandleRef.current === activeHandle
) {
return
}
@@ -201,6 +226,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputCompositionHandleRef,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
@@ -216,7 +242,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (
!activeHandle ||
!liveInputTerminalHandles.has(activeHandle) ||
liveInputComposingRef.current
liveInputCompositionHandleRef.current === activeHandle
) {
return
}