mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(mobile): let touch cancel pending hardware focus
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user