mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(mobile): fence native IME composition
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user