fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)

* fix(mobile): route the bottom drawer's keyboard through the platform seam

Fill-mode sheets called Keyboard.metrics() directly, which react-native-web
does not implement, so opening one on the page threw and the shell
re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose
native half seeds from metrics() and carries the event duration, and whose
web half answers from the window (duration 0). The fill/content-sized seed
rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps
Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): retire the drawer's exemption from the page keyboard census

The bottom drawer now reads the keyboard seam, so no module in the
source-control or review closures names react-native-web's Keyboard stub.
The census also flags Keyboard.metrics, which the stub lacks.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the drawer an imperative keyboard pair from the seam

The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight
beside its hooks. The drawer's effect is back to its original shape with
only its Keyboard calls swapped for the pair, and useSoftKeyboard is back
to {height, visible} with no metrics() seed. Seeding every consumer opened
an iOS window between willHide and didHide where metrics() still reads
open. The web pair answers from visualViewport, so it stays silent inside
the shell and lifts sheets in a plain mobile browser.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): start the web keyboard subscription from the current strip

A keyboard already covering the page when subscribeSoftKeyboard attached
never produced onHide when it closed, so the occlusion hook and a seeded
fill sheet stayed lifted. Outside the shell only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-23 19:46:52 -04:00
committed by GitHub
parent 069dc8a1d8
commit 5b6a857e41
9 changed files with 546 additions and 80 deletions
@@ -25,17 +25,6 @@ const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
const SEAM = 'src/platform/keyboard-occlusion.web.ts'
/**
* The one module that subscribes to the stub and is not the seam, exempt by name.
*
* `mounted-bottom-drawer.tsx` reads more than a height: `Keyboard.metrics()` for a sheet opened
* over an already-raised keyboard, and each event's `duration` to animate with it. The seam models
* neither, and the drawer is in C1's, C2's, C3's and C5's closures as well as these two, so moving
* it is a change to every page rather than to this domain. Its listeners are inert on the web the
* same way, which is exactly why the composer inside it takes its own padding here.
*/
const SUBSCRIBES_TO_THE_STUB = ['src/components/mounted-bottom-drawer.tsx']
/**
* The seam itself, which is the one place allowed to name the stub.
*
@@ -56,7 +45,9 @@ function keyboardSubscribers(closure, rootDir = mobileDir) {
.filter((file) => !SEAM_FILES.includes(file))
.filter((file) => {
try {
return readFileSync(join(rootDir, file), 'utf8').includes('Keyboard.addListener')
const source = readFileSync(join(rootDir, file), 'utf8')
// `metrics()` too: the stub has none, so a page module calling it throws on the spot.
return source.includes('Keyboard.addListener') || source.includes('Keyboard.metrics')
} catch {
return false
}
@@ -69,7 +60,7 @@ describeClosure(
() => {
it.each([HUB, REVIEW])('measures it through the seam and nowhere else: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(keyboardSubscribers(closure)).toEqual(SUBSCRIBES_TO_THE_STUB)
expect(keyboardSubscribers(closure)).toEqual([])
})
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
@@ -102,17 +93,6 @@ describeClosure(
rmSync(root, { recursive: true, force: true })
}
})
it('names an exemption that is really in both closures, so it cannot outlive its subject', async () => {
const [hub, review] = await Promise.all([
mobileWebAppRouteClosure(HUB),
mobileWebAppRouteClosure(REVIEW)
])
for (const file of SUBSCRIBES_TO_THE_STUB) {
expect(hub.local, file).toContain(file)
expect(review.local, file).toContain(file)
}
})
},
240_000
)
@@ -0,0 +1,172 @@
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
type Harness = {
/** What `currentSoftKeyboardHeight()` answers: a keyboard already up when the sheet opens. */
current: number
show: ((height: number, duration: number) => void) | null
hide: ((duration: number) => void) | null
timings: { to: number; duration: number | undefined }[]
/** Every shared-value write: a seed lands here directly, without a timing. */
writes: number[]
}
const harness = vi.hoisted((): Harness => ({
current: 0,
show: null,
hide: null,
timings: [],
writes: []
}))
vi.mock('../platform/keyboard-occlusion', () => ({
currentSoftKeyboardHeight: () => harness.current,
subscribeSoftKeyboard: (
show: (height: number, duration: number) => void,
hide: (duration: number) => void
) => {
harness.show = show
harness.hide = hide
return () => {
harness.show = null
harness.hide = null
}
}
}))
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('react-native', () => ({
Keyboard: { dismiss: () => {} },
Modal: 'Modal',
Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
View: 'View',
useWindowDimensions: () => ({ width: 412, height: 900 })
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain: Record<string, unknown> = {}
for (const method of [
'activeOffsetY',
'simultaneousWithExternalGesture',
'onBegin',
'onUpdate',
'onEnd'
]) {
chain[method] = () => chain
}
return {
Gesture: { Pan: () => chain, Native: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
useSharedValue: (initial: number) => {
let value = initial
return {
get value() {
return value
},
set value(next: number) {
value = next
harness.writes.push(next)
}
}
},
useAnimatedStyle: () => ({}),
useAnimatedScrollHandler: () => () => {},
withSpring: (to: number) => to,
withTiming: (to: number, config?: { duration?: number }) => {
harness.timings.push({ to, duration: config?.duration })
return to
},
runOnJS: (fn: () => void) => fn,
interpolate: () => 0,
Extrapolation: { CLAMP: 'clamp' }
}))
import { MountedBottomDrawer } from './mounted-bottom-drawer'
function sheet(fillAvailable: boolean) {
return (
<MountedBottomDrawer
visible
fillAvailable={fillAvailable}
onClose={() => {}}
onHidden={() => {}}
>
{null}
</MountedBottomDrawer>
)
}
function render(fillAvailable: boolean): ReactTestRenderer {
let renderer!: ReactTestRenderer
act(() => {
renderer = create(sheet(fillAvailable))
})
return renderer
}
function keyboardShows(height: number, duration: number): void {
act(() => harness.show?.(height, duration))
}
function keyboardHides(duration: number): void {
act(() => harness.hide?.(duration))
}
function marginBottom(renderer: ReactTestRenderer): unknown {
const node = renderer.root.find((candidate) => candidate.props.testID === 'bottom-drawer-sheet')
const style: unknown[] = [node.props.style].flat(Infinity)
return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null))
.marginBottom
}
describe('the drawer riding the keyboard seam', () => {
afterEach(() => {
harness.current = 0
harness.timings.length = 0
harness.writes.length = 0
})
it('docks a fill sheet on a keyboard already up when it opens', () => {
harness.current = 300
const renderer = render(true)
expect(marginBottom(renderer)).toBe(300)
act(() => renderer.unmount())
})
it('does not seed a content-sized sheet from a keyboard already up', () => {
harness.current = 300
const renderer = render(false)
expect(harness.writes).not.toContain(300)
// It still rides the keyboard's next event.
keyboardShows(310, 0)
expect(harness.timings).toContainEqual({ to: 310, duration: 250 })
act(() => renderer.unmount())
})
it('lifts with the event duration and drops back when the keyboard hides', () => {
const renderer = render(true)
keyboardShows(280, 120)
expect(marginBottom(renderer)).toBe(280)
expect(harness.timings).toContainEqual({ to: 280, duration: 120 })
keyboardHides(0)
expect(marginBottom(renderer)).toBe(0)
// An event without a duration still animates.
expect(harness.timings).toContainEqual({ to: 0, duration: 250 })
act(() => renderer.unmount())
})
it('unsubscribes on unmount', () => {
const renderer = render(true)
expect(harness.show).not.toBeNull()
act(() => renderer.unmount())
expect(harness.show).toBeNull()
})
})
@@ -0,0 +1,120 @@
// @vitest-environment happy-dom
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
// The page's module graph: react-native-web's own `Keyboard` (no `metrics`, an inert
// `addListener`) and the web sibling of the keyboard seam, as the page bundler resolves them.
vi.mock('react-native', async () => {
const React = await import('react')
const { default: Keyboard }: { default: unknown } =
// @ts-expect-error TS7016: react-native-web ships no type declarations.
await import('react-native-web/dist/exports/Keyboard')
return {
BackHandler: { addEventListener: () => ({ remove: () => {} }) },
Keyboard,
Modal: 'Modal',
Platform: { OS: 'web', select: (options: { web?: unknown }) => options.web },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
View: 'View',
// Subscribed, as react-native-web's is: the shell shortening the WebView is a resize.
useWindowDimensions: () => {
const read = () => ({ width: window.innerWidth, height: window.innerHeight })
const [size, setSize] = React.useState(read)
React.useEffect(() => {
const onResize = () => setSize(read())
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return size
}
}
})
vi.mock(
'../platform/keyboard-occlusion',
async () => await import('../platform/keyboard-occlusion.web')
)
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain: Record<string, unknown> = {}
for (const method of [
'activeOffsetY',
'simultaneousWithExternalGesture',
'onBegin',
'onUpdate',
'onEnd'
]) {
chain[method] = () => chain
}
return {
Gesture: { Pan: () => chain, Native: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
useSharedValue: (initial: number) => ({ value: initial }),
useAnimatedStyle: () => ({}),
useAnimatedScrollHandler: () => () => {},
withSpring: (to: number) => to,
withTiming: (to: number) => to,
runOnJS: (fn: () => void) => fn,
interpolate: () => 0,
Extrapolation: { CLAMP: 'clamp' }
}))
import { MountedBottomDrawer } from './mounted-bottom-drawer'
const RESTING_HEIGHT = 900
const IME_HEIGHT = 300
function setWindowHeight(height: number): void {
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height })
window.dispatchEvent(new Event('resize'))
}
function renderFillSheet(): ReactTestRenderer {
let renderer!: ReactTestRenderer
act(() => {
renderer = create(
<MountedBottomDrawer visible fillAvailable onClose={() => {}} onHidden={() => {}}>
{null}
</MountedBottomDrawer>
)
})
return renderer
}
function sheetStyle(renderer: ReactTestRenderer): Record<string, unknown> {
const sheet = renderer.root.find((node) => node.props.testID === 'bottom-drawer-sheet')
const style: unknown[] = [sheet.props.style].flat(Infinity)
return Object.assign({}, ...style.filter((entry) => typeof entry === 'object' && entry !== null))
}
describe('a fill-mode sheet on the page', () => {
afterEach(() => setWindowHeight(RESTING_HEIGHT))
it('mounts against react-native-web, whose Keyboard has no metrics()', () => {
setWindowHeight(RESTING_HEIGHT)
const renderer = renderFillSheet()
expect(sheetStyle(renderer).marginBottom).toBe(0)
act(() => renderer.unmount())
})
it('does not lift over a keyboard the shell already shortened the WebView for', () => {
setWindowHeight(RESTING_HEIGHT)
const renderer = renderFillSheet()
act(() => setWindowHeight(RESTING_HEIGHT - IME_HEIGHT))
// The shortened window is the whole avoidance: the sheet's height follows it and nothing lifts.
expect(sheetStyle(renderer)).toMatchObject({
marginBottom: 0,
height: RESTING_HEIGHT - IME_HEIGHT - 24 - 16
})
act(() => renderer.unmount())
})
})
+17 -17
View File
@@ -28,6 +28,7 @@ import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useBackClaim } from '../navigation/use-back-claim'
import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion'
const DISMISS_THRESHOLD = 80
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
@@ -129,8 +130,9 @@ export function MountedBottomDrawer({
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
// and give us the exact height to shift the drawer by.
// useAnimatedKeyboard). The keyboard seam's events work on both platforms
// and give the exact height; inside the shell's page none fire, because
// the shell shortens the WebView above the IME.
useEffect(() => {
// Pinned-under sheets stay visible for size but must not ride the keyboard —
// only the top interactive sheet owns inset/lift.
@@ -159,26 +161,24 @@ export function MountedBottomDrawer({
// keyboard before listeners attach. Seed only in fill mode so content-sized
// outer sheets do not inherit a stale metrics height after an inner dismiss.
if (fillAvailable) {
const existing = Keyboard.metrics()
if (existing != null && existing.height > 0) {
applyKeyboardHeight(existing.height)
const existing = currentSoftKeyboardHeight()
if (existing > 0) {
applyKeyboardHeight(existing)
}
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (e) => {
applyKeyboardHeight(e.endCoordinates.height, e.duration || 250)
})
const onHide = Keyboard.addListener(hideEvent, (e) => {
setKeyboardInset(0)
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
})
const unsubscribe = subscribeSoftKeyboard(
(height, duration) => {
applyKeyboardHeight(height, duration || 250)
},
(duration) => {
setKeyboardInset(0)
keyboardOffset.value = withTiming(0, { duration: duration || 250 })
}
)
return () => {
onShow.remove()
onHide.remove()
unsubscribe()
keyboardOffset.value = 0
setKeyboardInset(0)
}
@@ -0,0 +1,38 @@
/**
* Who may ask React Native's `Keyboard` for geometry: the keyboard seam, and one named exception.
*
* react-native-web's `Keyboard` has no `metrics` and an `addListener` that never fires, so a page
* screen that asks it directly either throws (a fill sheet crashed the page on `metrics()`) or waits
* forever. The seam has a web sibling that answers from the window instead; everything else reads it.
*/
import { readFileSync } from 'node:fs'
import { relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { censusSourceFiles } from '../test-support/census-source-files'
const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url))
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url))
const SEAM = 'src/platform/keyboard-occlusion.ts'
/** A one-shot "open after the keyboard hides" with a timer fallback; it subscribes only while the
* seam reports a keyboard, which on the page is never, so there it cannot wait forever. */
const TAB_SHEET_AFTER_HIDE = 'src/session/use-mobile-session-terminal-send-actions.ts'
const asksKeyboard = (line: string): boolean =>
line.includes('Keyboard.metrics(') || line.includes('Keyboard.addListener(')
function callers(): string[] {
return censusSourceFiles(SRC_DIR)
.filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file))
.filter((file) => readFileSync(file, 'utf8').split('\n').some(asksKeyboard))
.map((file) => relative(MOBILE_DIR, file))
.sort()
}
describe("React Native's Keyboard geometry", () => {
it('is read only by the keyboard seam and the tab sheet that waits for a hide', () => {
// Also the presence precondition: the seam itself must match, or the matcher is broken.
expect(callers()).toEqual([SEAM, TAB_SHEET_AFTER_HIDE].sort())
})
})
@@ -2,7 +2,7 @@ import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type Listener = (event: { endCoordinates: { height: number } }) => void
type Listener = (event: { endCoordinates: { height: number }; duration: number }) => void
type KeyboardHarness = {
listeners: Map<string, Listener>
@@ -11,17 +11,21 @@ type KeyboardHarness = {
/** Every call, not the surviving subscriptions: a hook that subscribes and unsubscribes still
* costs a phone a render per keyboard event, which `listeners.size` alone would not show. */
addListenerCalls: number
/** What `Keyboard.metrics()` answers. */
metrics: { height: number } | undefined
}
const keyboard = vi.hoisted((): KeyboardHarness => ({
listeners: new Map(),
removed: [],
platform: 'ios',
addListenerCalls: 0
addListenerCalls: 0,
metrics: undefined
}))
vi.mock('react-native', () => ({
Keyboard: {
metrics: () => keyboard.metrics,
addListener: (name: string, listener: Listener) => {
keyboard.addListenerCalls += 1
keyboard.listeners.set(name, listener)
@@ -41,6 +45,8 @@ vi.mock('react-native', () => ({
}))
import {
currentSoftKeyboardHeight,
subscribeSoftKeyboard,
useKeyboardAvoidingPadding,
useKeyboardOcclusion,
useSoftKeyboard,
@@ -86,6 +92,7 @@ describe('the keyboard the phone reports', () => {
keyboard.removed.length = 0
keyboard.platform = 'ios'
keyboard.addListenerCalls = 0
keyboard.metrics = undefined
lift = 0
padding = 0
keyboardState = { height: 0, visible: false }
@@ -104,11 +111,14 @@ describe('the keyboard the phone reports', () => {
it('lifts by the height the event carries and drops back on hide', async () => {
await mount()
await act(async () => {
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } })
keyboard.listeners.get('keyboardWillShow')?.({
endCoordinates: { height: 336 },
duration: 250
})
})
expect(lift).toBe(336)
await act(async () => {
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } })
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 })
})
expect(lift).toBe(0)
})
@@ -116,7 +126,7 @@ describe('the keyboard the phone reports', () => {
it('never reports a negative height, whatever the event says', async () => {
await mount()
await act(async () => {
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 } })
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: -10 }, duration: 0 })
})
expect(lift).toBe(0)
})
@@ -144,11 +154,14 @@ describe('the keyboard the phone reports', () => {
await mountComponent(StateHarness)
expect(keyboard.addListenerCalls).toBe(2)
await act(async () => {
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } })
keyboard.listeners.get('keyboardWillShow')?.({
endCoordinates: { height: 336 },
duration: 250
})
})
expect(keyboardState).toEqual({ height: 336, visible: true })
await act(async () => {
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } })
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 0 })
})
expect(keyboardState).toEqual({ height: 0, visible: false })
})
@@ -156,8 +169,34 @@ describe('the keyboard the phone reports', () => {
it('calls a keyboard that reports no height open anyway, because the event is the fact', async () => {
await mountComponent(StateHarness)
await act(async () => {
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 } })
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 }, duration: 0 })
})
expect(keyboardState).toEqual({ height: 0, visible: true })
})
it('hands a sheet each event with its duration, and removes both listeners', () => {
const calls: string[] = []
const unsubscribe = subscribeSoftKeyboard(
(height, duration) => calls.push(`show ${height} ${duration}`),
(duration) => calls.push(`hide ${duration}`)
)
keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 }, duration: 250 })
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 180 })
expect(calls).toEqual(['show 336 250', 'hide 180'])
unsubscribe()
expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow'])
})
it('reads a keyboard already up from metrics(), and 0 when there is none', () => {
expect(currentSoftKeyboardHeight()).toBe(0)
keyboard.metrics = { height: 291 }
expect(currentSoftKeyboardHeight()).toBe(291)
})
it('never seeds the hook from metrics(), so a stale iOS reading cannot open it', async () => {
// RN clears metrics() on didHide, after the willHide this hook closes on.
keyboard.metrics = { height: 291 }
await mountComponent(StateHarness)
expect(keyboardState).toEqual({ height: 0, visible: false })
})
})
+36 -14
View File
@@ -21,27 +21,49 @@ const CLOSED: SoftKeyboardState = { height: 0, visible: false }
export function useSoftKeyboard(): SoftKeyboardState {
const [keyboard, setKeyboard] = useState<SoftKeyboardState>(CLOSED)
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (event) => {
useEffect(
() =>
// The keyboard's own height already describes the obscured area; the consumer adds whatever
// clearance it wants above it. Open is the event, not the height: a keyboard that reports 0
// is still one nobody wants the terminal re-fitted under.
setKeyboard({ height: Math.max(0, event.endCoordinates.height), visible: true })
})
const onHide = Keyboard.addListener(hideEvent, () => setKeyboard(CLOSED))
return () => {
onShow.remove()
onHide.remove()
}
}, [])
subscribeSoftKeyboard(
(height) => setKeyboard({ height: Math.max(0, height), visible: true }),
() => setKeyboard(CLOSED)
),
[]
)
return keyboard
}
/**
* The keyboard as events, for a consumer that animates with each one (the event's `duration`) rather
* than rendering from state. iOS is told `will`, Android `did`.
*/
export function subscribeSoftKeyboard(
onShow: (height: number, duration: number) => void,
onHide: (duration: number) => void
): () => void {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const show = Keyboard.addListener(showEvent, (event) =>
onShow(event.endCoordinates.height, event.duration)
)
const hide = Keyboard.addListener(hideEvent, (event) => onHide(event.duration))
return () => {
show.remove()
hide.remove()
}
}
/**
* The keyboard already up, which sends no show event to a late subscriber. React Native sets this on
* `did` events, so on iOS it can still read open between `willHide` and `didHide`.
*/
export function currentSoftKeyboardHeight(): number {
return Keyboard.metrics()?.height ?? 0
}
/** The occluded strip alone, for the callers that lift by it and never ask whether it is open. */
export function useKeyboardOcclusion(): number {
return useSoftKeyboard().height
@@ -3,6 +3,8 @@ import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
currentSoftKeyboardHeight,
subscribeSoftKeyboard,
useKeyboardAvoidingPadding,
useKeyboardOcclusion,
useSoftKeyboard,
@@ -201,6 +203,65 @@ describe('the keyboard the browser reports', () => {
})
})
/** The pair a sheet animates with: events rather than state, each with duration 0. */
describe('the keyboard as events, for a sheet', () => {
it('shows by the uncovered strip and hides once when it closes', () => {
const calls: string[] = []
const unsubscribe = subscribeSoftKeyboard(
(height, duration) => calls.push(`show ${height} ${duration}`),
(duration) => calls.push(`hide ${duration}`)
)
viewport?.resizeTo(464)
viewport?.resizeTo(LAYOUT_HEIGHT)
viewport?.resizeTo(LAYOUT_HEIGHT)
expect(calls).toEqual(['show 336 0', 'hide 0'])
unsubscribe()
expect(viewport?.counts).toEqual({ resize: 0, scroll: 0 })
})
it('hides a keyboard that was already up when it subscribed', async () => {
viewport?.resizeTo(464)
const calls: string[] = []
const unsubscribe = subscribeSoftKeyboard(
(height) => calls.push(`show ${height}`),
(duration) => calls.push(`hide ${duration}`)
)
viewport?.resizeTo(LAYOUT_HEIGHT)
expect(calls).toEqual(['hide 0'])
unsubscribe()
// The hook seeds from the same strip, so it must come back down too.
viewport?.resizeTo(464)
await mount()
expect(lift).toBe(336)
await act(async () => viewport?.resizeTo(LAYOUT_HEIGHT))
expect(lift).toBe(0)
})
it('stays silent when nothing is covered, which is the shell shortening the WebView', () => {
const calls: string[] = []
const unsubscribe = subscribeSoftKeyboard(
() => calls.push('show'),
() => calls.push('hide')
)
viewport?.resizeTo(LAYOUT_HEIGHT)
expect(calls).toEqual([])
unsubscribe()
})
it('reads a keyboard already up, and 0 without a visual viewport', () => {
viewport?.resizeTo(464)
expect(currentSoftKeyboardHeight()).toBe(336)
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true })
expect(currentSoftKeyboardHeight()).toBe(0)
expect(() =>
subscribeSoftKeyboard(
() => {},
() => {}
)()
).not.toThrow()
})
})
const LAYOUT_WIDTH = 400
let keyboardState: SoftKeyboardState = { height: 0, visible: false }
+51 -17
View File
@@ -32,8 +32,8 @@ import { useEffect, useState } from 'react'
* `scale` is read defensively because older WebViews do not implement it, and treating its absence
* as zoomed would answer 0 for every keyboard on them.
*
* No `visualViewport` at all is 0 rather than a guess — that guard is in the effect below, which
* is also the only thing that can act on it, and a second copy here was unreachable.
* No `visualViewport` at all is 0 rather than a guess — that guard is in the two readers below,
* which are the only things that can act on it.
*/
function occlusion(viewport: VisualViewport): number {
if ((viewport.scale ?? 1) !== 1) {
@@ -42,25 +42,59 @@ function occlusion(viewport: VisualViewport): number {
return Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop))
}
/** Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property. */
function visualViewport(): VisualViewport | undefined {
return window.visualViewport ?? undefined
}
/**
* The strip as events, with duration 0: the browser has already moved the viewport by the time it
* says so. Inside the shell nothing fires, because the shell shortens the WebView above the IME and
* the strip stays 0; in a plain mobile browser the keyboard overlays the page and this is the lift.
*/
export function subscribeSoftKeyboard(
onShow: (height: number, duration: number) => void,
onHide: (duration: number) => void
): () => void {
const viewport = visualViewport()
if (viewport === undefined) {
return () => {}
}
let open = occlusion(viewport) > 0
const read = (): void => {
const height = occlusion(viewport)
if (height > 0) {
open = true
onShow(height, 0)
} else if (open) {
open = false
onHide(0)
}
}
viewport.addEventListener('resize', read)
viewport.addEventListener('scroll', read)
return () => {
viewport.removeEventListener('resize', read)
viewport.removeEventListener('scroll', read)
}
}
/** A keyboard already up when a composer opens gets no event at all. */
export function currentSoftKeyboardHeight(): number {
const viewport = visualViewport()
return viewport === undefined ? 0 : occlusion(viewport)
}
export function useKeyboardOcclusion(): number {
const [keyboardLift, setKeyboardLift] = useState(0)
useEffect(() => {
// Both shapes: `null` is what the DOM declares, `undefined` is a WebView without the property.
const viewport = window.visualViewport
if (viewport === null || viewport === undefined) {
return
}
const read = (): void => setKeyboardLift(occlusion(viewport))
// Read once on mount: a composer opened while the keyboard is already up gets no event at all.
read()
viewport.addEventListener('resize', read)
viewport.addEventListener('scroll', read)
return () => {
viewport.removeEventListener('resize', read)
viewport.removeEventListener('scroll', read)
}
setKeyboardLift(currentSoftKeyboardHeight())
return subscribeSoftKeyboard(
(height) => setKeyboardLift(height),
() => setKeyboardLift(0)
)
}, [])
return keyboardLift