fix(mobile): let touch cancel pending hardware focus

This commit is contained in:
Neil
2026-09-05 21:38:48 -07:00
parent b513cce6f0
commit e0e5add59d
2 changed files with 160 additions and 5 deletions
@@ -8,6 +8,7 @@ const runtime = vi.hoisted(() => ({
focused: true,
connected: true,
listener: null as null | ((event: { connected: boolean }) => void),
appStateListener: null as null | ((state: string) => void),
register: vi.fn(() => vi.fn()),
frames: [] as Array<() => void>
}))
@@ -25,7 +26,18 @@ vi.mock('expo-router', async () => {
vi.mock('./mobile-hardware-keyboard-registry', () => ({
registerMobileHardwareKeyboardScope: runtime.register
}))
vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove: vi.fn() }) } }))
vi.mock('react-native', () => ({
AppState: {
addEventListener: (_event: string, listener: typeof runtime.appStateListener) => {
runtime.appStateListener = listener
return {
remove: () => {
runtime.appStateListener = null
}
}
}
}
}))
vi.mock('@orca/expo-hardware-keyboard-navigation', () => ({
isHardwareKeyboardConnected: () => runtime.connected,
addHardwareKeyboardConnectionListener: (listener: typeof runtime.listener) => {
@@ -102,3 +114,137 @@ it('restores soft input on disconnect and only refocuses a visible route on reco
act(() => runtime.frames.shift()?.())
expect(input.focus).toHaveBeenCalledTimes(2)
})
function installCancellableFocusFrames() {
vi.useFakeTimers()
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 16))
vi.stubGlobal('cancelAnimationFrame', clearTimeout)
}
it('cancels queued hardware focus when the keyboard disconnects before the frame', () => {
installCancellableFocusFrames()
const input = { focus: vi.fn(), setNativeProps: vi.fn(), isFocused: () => false }
const inputRef = { current: input } as never
function Screen() {
useHardwareKeyboardTextInputFocus({ enabled: true, inputRef, surfaceId: 'chat' })
return null
}
act(() => {
renderer = create(createElement(Screen))
})
act(() => {
runtime.listener?.({ connected: false })
})
act(() => {
vi.runAllTimers()
})
expect(input.focus).not.toHaveBeenCalled()
expect(input.setNativeProps).toHaveBeenLastCalledWith({ showSoftInputOnFocus: true })
expect(vi.getTimerCount()).toBe(0)
})
it('cancels touch refocus and hardware verification when the retained route blurs', () => {
installCancellableFocusFrames()
const input = { focus: vi.fn(), blur: vi.fn(), setNativeProps: vi.fn(), isFocused: () => false }
const inputRef = { current: input } as never
let touch = () => {}
function Screen() {
touch = useHardwareKeyboardTextInputFocus({
enabled: true,
inputRef,
surfaceId: 'chat'
}).handleTouchStart
return null
}
act(() => {
renderer = create(createElement(Screen))
})
act(() => {
vi.advanceTimersByTime(16)
})
expect(input.focus).toHaveBeenCalledOnce()
act(() => {
touch()
})
expect(input.blur).toHaveBeenCalledOnce()
runtime.focused = false
act(() => renderer?.update(createElement(Screen)))
act(() => {
vi.runAllTimers()
})
expect(input.focus).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('resamples a missed disconnect on foreground and releases both subscriptions on unmount', () => {
installCancellableFocusFrames()
const input = { focus: vi.fn(), setNativeProps: vi.fn(), isFocused: () => false }
const inputRef = { current: input } as never
let softInput = true
function Screen() {
softInput = useHardwareKeyboardTextInputFocus({
enabled: true,
inputRef,
surfaceId: 'chat'
}).showSoftInputOnFocus
return null
}
act(() => {
renderer = create(createElement(Screen))
})
act(() => {
vi.advanceTimersByTime(16)
})
expect(softInput).toBe(false)
runtime.connected = false
act(() => {
runtime.appStateListener?.('active')
})
expect(softInput).toBe(true)
expect(input.setNativeProps).toHaveBeenLastCalledWith({ showSoftInputOnFocus: true })
act(() => {
renderer?.unmount()
renderer = null
})
act(() => {
vi.runAllTimers()
})
expect(input.focus).toHaveBeenCalledOnce()
expect(runtime.listener).toBeNull()
expect(runtime.appStateListener).toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it.each([0, 16])(
'keeps software input enabled after touch at %ims supersedes hardware focus',
(elapsed) => {
installCancellableFocusFrames()
const input = { focus: vi.fn(), blur: vi.fn(), setNativeProps: vi.fn(), isFocused: () => false }
const inputRef = { current: input } as never
let touch = () => {}
function Screen() {
touch = useHardwareKeyboardTextInputFocus({
enabled: true,
inputRef,
surfaceId: 'chat'
}).handleTouchStart
return null
}
act(() => {
renderer = create(createElement(Screen))
})
act(() => {
vi.advanceTimersByTime(elapsed)
})
act(() => {
touch()
})
input.focus.mockClear()
act(() => {
vi.runAllTimers()
})
expect(input.setNativeProps).toHaveBeenLastCalledWith({ showSoftInputOnFocus: true })
expect(input.focus).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
}
)
@@ -17,6 +17,7 @@ export function useHardwareKeyboardTextInputFocus(options: {
const { enabled, inputRef, surfaceId } = options
const [suppressSoftInput, setSuppressSoftInput] = useState(false)
const touchFocusFrameRef = useRef<number | null>(null)
const cancelAutomaticFocusRef = useRef<(() => void) | null>(null)
const [connected, setConnected] = useState(isHardwareKeyboardConnected)
useEffect(() => {
@@ -58,15 +59,21 @@ export function useHardwareKeyboardTextInputFocus(options: {
}
}, 120)
})
return () => {
const cancelAutomaticFocus = () => {
cancelAnimationFrame(frame)
if (verifyTimer !== null) {
clearTimeout(verifyTimer)
verifyTimer = null
}
}
cancelAutomaticFocusRef.current = cancelAutomaticFocus
return () => {
cancelAutomaticFocus()
cancelAutomaticFocusRef.current = null
if (touchFocusFrameRef.current !== null) {
cancelAnimationFrame(touchFocusFrameRef.current)
}
inputRef.current?.setNativeProps({ showSoftInputOnFocus: true })
if (verifyTimer !== null) {
clearTimeout(verifyTimer)
}
}
}, [enabled, connected, inputRef, surfaceId])
)
@@ -85,6 +92,8 @@ export function useHardwareKeyboardTextInputFocus(options: {
if (!suppressSoftInput || !input) {
return
}
// A touch request supersedes both phases of automatic hardware focus.
cancelAutomaticFocusRef.current?.()
setSuppressSoftInput(false)
input.setNativeProps({ showSoftInputOnFocus: true })
input.blur()