From e0e5add59d05d86034f7ebae650762932e741d4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:38:48 -0700 Subject: [PATCH] fix(mobile): let touch cancel pending hardware focus --- .../hardware-keyboard-focus-lifecycle.test.ts | 148 +++++++++++++++++- .../use-hardware-keyboard-text-input-focus.ts | 17 +- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/mobile/src/hardware-keyboard/hardware-keyboard-focus-lifecycle.test.ts b/mobile/src/hardware-keyboard/hardware-keyboard-focus-lifecycle.test.ts index 0fb4abf7256..c77cbef7fe6 100644 --- a/mobile/src/hardware-keyboard/hardware-keyboard-focus-lifecycle.test.ts +++ b/mobile/src/hardware-keyboard/hardware-keyboard-focus-lifecycle.test.ts @@ -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) + } +) diff --git a/mobile/src/hardware-keyboard/use-hardware-keyboard-text-input-focus.ts b/mobile/src/hardware-keyboard/use-hardware-keyboard-text-input-focus.ts index 65c7c3a7d66..48be87ee977 100644 --- a/mobile/src/hardware-keyboard/use-hardware-keyboard-text-input-focus.ts +++ b/mobile/src/hardware-keyboard/use-hardware-keyboard-text-input-focus.ts @@ -17,6 +17,7 @@ export function useHardwareKeyboardTextInputFocus(options: { const { enabled, inputRef, surfaceId } = options const [suppressSoftInput, setSuppressSoftInput] = useState(false) const touchFocusFrameRef = useRef(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()