fix(stage-layouts): fix iOS keyboard closing right after opening (#2436)

This commit is contained in:
Doji
2026-09-02 12:21:51 +08:00
committed by GitHub
parent 9c213115f8
commit 6697cb4c87
6 changed files with 32 additions and 363 deletions
@@ -1,9 +1,8 @@
import type { ViewportProfile, ViewportRectangle, ViewportSample } from './adaptive-input-geometry'
import type { ViewportRectangle } from './adaptive-input-geometry'
import { describe, expect, it } from 'vitest'
import {
calculateCachedViewportHeight,
calculateKeyboardShift,
calculateVisualViewportLayout,
} from './adaptive-input-geometry'
@@ -27,24 +26,6 @@ function createRectangle(options: {
}
}
function createViewportProfile(options: Partial<ViewportProfile> = {}): ViewportProfile {
return {
displayMode: 'browser',
height: 714,
width: 390,
...options,
}
}
function createViewportSample(options: Partial<ViewportSample> = {}): ViewportSample {
return {
bottomHiddenByKeyboard: 310,
measuredAt: 1_000,
profile: createViewportProfile(),
...options,
}
}
describe('adaptive input geometry', () => {
it('moves a bottom input area above a docked keyboard', () => {
const target = createRectangle({ top: 780, bottom: 844 })
@@ -150,94 +131,4 @@ describe('adaptive input geometry', () => {
expect(layout.offsetTop).toBe(310)
})
// https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API
it('calculates the pre-focus height from a previous keyboard measurement', () => {
// ROOT CAUSE:
//
// Safari starts its native page pan before it reports stable Visual Viewport geometry.
// A correction after focus cannot remove the compositor frames that move the Stage.
//
// Before the fix, AIRI waited for a Visual Viewport event before it changed the chat height.
//
// We fixed this by applying a recent height hidden by the keyboard before focus.
// The focused control is inside the predicted visible area when Safari starts its focus policy.
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile(),
2_000,
)
expect(cachedHeight).toBe(404)
})
it('applies a recent height hidden by the keyboard to a small layout-height change', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile({ height: 734 }),
2_000,
)
expect(cachedHeight).toBe(424)
})
it('rejects a cached measurement after an orientation change', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile({ height: 390, width: 844 }),
2_000,
)
expect(cachedHeight).toBeUndefined()
})
it('rejects a cached measurement from another display mode', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile({ displayMode: 'standalone' }),
2_000,
)
expect(cachedHeight).toBeUndefined()
})
it('rejects a cached measurement after a large layout-height change', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile({ height: 834 }),
2_000,
)
expect(cachedHeight).toBeUndefined()
})
it('rejects a cached measurement from another layout width', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile({ width: 430 }),
2_000,
)
expect(cachedHeight).toBeUndefined()
})
it('rejects a cached measurement without a keyboard-sized hidden height', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample({ bottomHiddenByKeyboard: 60 }),
createViewportProfile(),
2_000,
)
expect(cachedHeight).toBeUndefined()
})
it('rejects an old cached measurement', () => {
const cachedHeight = calculateCachedViewportHeight(
createViewportSample(),
createViewportProfile(),
3_601_000,
)
expect(cachedHeight).toBeUndefined()
})
})
@@ -14,26 +14,6 @@ export interface ViewportRectangle {
width: number
}
/** The viewport properties that determine whether a keyboard sample can be reused. */
export interface ViewportProfile {
/** The browser or installed-app mode that owns the viewport policy. */
displayMode: 'browser' | 'standalone'
/** The layout viewport height in CSS pixels. */
height: number
/** The layout viewport width in CSS pixels. */
width: number
}
/** A measured keyboard overlap that can prepare a later focus operation. */
export interface ViewportSample {
/** The layout viewport height hidden by the keyboard, in CSS pixels. */
bottomHiddenByKeyboard: number
/** The Unix timestamp in milliseconds when the sample was recorded. */
measuredAt: number
/** The viewport profile that produced the sample. */
profile: ViewportProfile
}
/** The Visual Viewport values used by the fallback layout policy. */
export interface VisualViewportMeasurement {
/** The current visual viewport height in CSS pixels. */
@@ -69,50 +49,6 @@ export interface VisualViewportLayout {
/** Changes below this threshold can come from browser controls instead of a software keyboard. */
const KEYBOARD_HEIGHT_LOSS_THRESHOLD = 100
/** A sample expires before a long-lived tab can reuse geometry from an earlier keyboard mode. */
const KEYBOARD_SAMPLE_MAX_AGE = 30 * 60 * 1000
/** A larger height change invalidates the sample because the browser layout is no longer comparable. */
const LAYOUT_HEIGHT_CHANGE_LIMIT = 80
/** A width change larger than rounding noise identifies a different layout viewport. */
const LAYOUT_WIDTH_CHANGE_LIMIT = 2
/**
* Calculates a pre-focus viewport height from a recent compatible sample.
*
* The result is undefined when the sample is too old or its viewport profile is not compatible.
*/
export function calculateCachedViewportHeight(
sample: ViewportSample,
currentProfile: ViewportProfile,
now: number,
): number | undefined {
const sampleAge = now - sample.measuredAt
if (sampleAge < 0 || sampleAge > KEYBOARD_SAMPLE_MAX_AGE)
return undefined
if (sample.profile.displayMode !== currentProfile.displayMode)
return undefined
const sampleIsLandscape = sample.profile.width > sample.profile.height
const currentIsLandscape = currentProfile.width > currentProfile.height
if (sampleIsLandscape !== currentIsLandscape)
return undefined
if (Math.abs(sample.profile.width - currentProfile.width) > LAYOUT_WIDTH_CHANGE_LIMIT)
return undefined
if (Math.abs(sample.profile.height - currentProfile.height) > LAYOUT_HEIGHT_CHANGE_LIMIT)
return undefined
if (sample.bottomHiddenByKeyboard <= KEYBOARD_HEIGHT_LOSS_THRESHOLD)
return undefined
const predictedHeight = currentProfile.height - sample.bottomHiddenByKeyboard
return predictedHeight > 0 ? predictedHeight : undefined
}
/**
* Resolves keyboard visibility and available edges from one Visual Viewport measurement.
*
@@ -163,7 +163,7 @@ describe('adaptive input', () => {
adaptiveInput.dispose()
})
it('uses cached pre-layout after the closing viewport becomes stable', () => {
it('keeps native touch activation after the closing viewport becomes stable', () => {
const viewport = document.createElement('div')
const area = document.createElement('div')
const textarea = document.createElement('textarea')
@@ -186,11 +186,11 @@ describe('adaptive input', () => {
const pointerDown = createPointerDown()
const nativeActivationContinues = textarea.dispatchEvent(pointerDown)
expect(nativeActivationContinues).toBe(false)
expect(pointerDown.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(textarea)
expect(adaptiveInput.layout.keyboardVisible).toBe(true)
expect(viewport.style.height).toBe(`${keyboardViewportHeight}px`)
expect(nativeActivationContinues).toBe(true)
expect(pointerDown.defaultPrevented).toBe(false)
expect(document.activeElement).toBe(document.body)
expect(adaptiveInput.layout.keyboardVisible).toBe(false)
expect(viewport.style.height).toBe(`${layoutHeight}px`)
adaptiveInput.dispose()
})
@@ -1,7 +1,6 @@
import type { AdaptiveInputFocusPhase, ViewportProfile, ViewportSample } from './adaptive-input-geometry'
import type { AdaptiveInputFocusPhase } from './adaptive-input-geometry'
import {
calculateCachedViewportHeight,
calculateKeyboardShift,
calculateVisualViewportLayout,
toViewportRectangle,
@@ -62,14 +61,8 @@ const TEXT_ENTRY_SELECTOR = [
'[contenteditable]:not([contenteditable="false"])',
].join(',')
function readViewportProfile(targetWindow: Window): ViewportProfile {
return {
displayMode: targetWindow.matchMedia('(display-mode: standalone)').matches
? 'standalone'
: 'browser',
height: targetWindow.document.documentElement.clientHeight,
width: targetWindow.document.documentElement.clientWidth,
}
function readLayoutHeight(targetWindow: Window): number {
return targetWindow.document.documentElement.clientHeight
}
function isTextEntry(element: Element): boolean {
@@ -78,14 +71,13 @@ function isTextEntry(element: Element): boolean {
}
/**
* Owns keyboard measurements and focus timing for one adaptive input region.
* Owns keyboard measurements and layout timing for one adaptive input region.
*
* Construction starts event measurement. Call {@link dispose} when the owner releases the region.
* The controller reports each new layout through {@link ADAPTIVE_INPUT_LAYOUT_EVENT}.
*
* The Safari pre-focus path writes one synchronous inline height to `viewport`. This write must
* finish before `focus()`. The controller restores the previous height during disposal if the
* consumer has not replaced the value.
* The focus-out path writes one synchronous inline height to `viewport`. The controller restores
* the previous height during disposal if the consumer has not replaced the value.
*/
export class AdaptiveInput extends EventTarget {
private readonly abortController = new AbortController()
@@ -103,12 +95,9 @@ export class AdaptiveInput extends EventTarget {
private focusPhase: AdaptiveInputFocusPhase = 'idle'
private layoutValue: AdaptiveInputLayout
private pendingWindowScrollRepair = false
private predictedViewportHeight: number | undefined
private predictionTimeout: number | undefined
private referenceLayoutHeight: number
private synchronousHeightBeforePrediction: string | undefined
private synchronousHeightBeforeWrite: string | undefined
private synchronousHeightValue: string | undefined
private viewportSample: ViewportSample | undefined
private readonly overlaysContentBeforeStart?: boolean
@@ -146,17 +135,13 @@ export class AdaptiveInput extends EventTarget {
: 'idle'
const listenerOptions = { signal: this.abortController.signal }
this.area.addEventListener('pointerdown', this.onPointerDown, {
capture: true,
signal: this.abortController.signal,
})
targetWindow.document.addEventListener('focusin', this.onFocusIn, listenerOptions)
targetWindow.document.addEventListener('focusout', this.onFocusOut, listenerOptions)
this.virtualKeyboard?.addEventListener('geometrychange', this.requestMeasurement, listenerOptions)
this.visualViewport?.addEventListener('resize', this.requestMeasurement, listenerOptions)
this.visualViewport?.addEventListener('scroll', this.requestMeasurement, listenerOptions)
targetWindow.addEventListener('resize', this.requestMeasurement, listenerOptions)
targetWindow.addEventListener('orientationchange', this.onOrientationChange, listenerOptions)
targetWindow.addEventListener('orientationchange', this.requestMeasurement, listenerOptions)
this.requestMeasurement()
}
@@ -173,17 +158,14 @@ export class AdaptiveInput extends EventTarget {
if (this.animationFrame !== undefined)
this.targetWindow.cancelAnimationFrame(this.animationFrame)
if (this.predictionTimeout !== undefined)
this.targetWindow.clearTimeout(this.predictionTimeout)
if (this.virtualKeyboard && this.overlaysContentBeforeStart !== undefined)
this.virtualKeyboard.overlaysContent = this.overlaysContentBeforeStart
if (
this.synchronousHeightBeforePrediction !== undefined
this.synchronousHeightBeforeWrite !== undefined
&& this.viewport.style.height === this.synchronousHeightValue
) {
this.viewport.style.height = this.synchronousHeightBeforePrediction
this.viewport.style.height = this.synchronousHeightBeforeWrite
}
}
@@ -197,8 +179,7 @@ export class AdaptiveInput extends EventTarget {
private readonly measure = () => {
this.animationFrame = undefined
const currentProfile = readViewportProfile(this.targetWindow)
const currentLayoutHeight = currentProfile.height
const currentLayoutHeight = readLayoutHeight(this.targetWindow)
if (this.focusPhase === 'idle' && !this.pendingWindowScrollRepair)
this.referenceLayoutHeight = currentLayoutHeight
@@ -207,8 +188,6 @@ export class AdaptiveInput extends EventTarget {
let visibleHeight = currentLayoutHeight
let viewportBottom = currentLayoutHeight
let viewportOffsetTop = 0
let updateViewportNow = false
if (this.visualViewport) {
// WORKAROUND:
// NOTICE:
@@ -224,35 +203,10 @@ export class AdaptiveInput extends EventTarget {
pageTop: this.visualViewport.pageTop,
}, stableLayoutHeight, this.focusPhase)
const predictedHeight = this.predictedViewportHeight
const predictionIsActive = predictedHeight !== undefined && this.focusPhase === 'focused'
if (predictionIsActive && !viewportLayout.heightLossExceedsThreshold) {
keyboardVisible = true
visibleHeight = predictedHeight
viewportBottom = predictedHeight
}
else {
keyboardVisible = viewportLayout.keyboardVisible
visibleHeight = viewportLayout.height
viewportBottom = viewportLayout.visibleBottom
viewportOffsetTop = viewportLayout.offsetTop
if (viewportLayout.keyboardVisible) {
this.viewportSample = {
bottomHiddenByKeyboard: Math.max(0, stableLayoutHeight - viewportLayout.height),
measuredAt: Date.now(),
profile: currentProfile,
}
}
if (predictionIsActive && viewportLayout.heightLossExceedsThreshold) {
this.predictedViewportHeight = undefined
if (this.predictionTimeout !== undefined)
this.targetWindow.clearTimeout(this.predictionTimeout)
this.predictionTimeout = undefined
updateViewportNow = true
}
}
keyboardVisible = viewportLayout.keyboardVisible
visibleHeight = viewportLayout.height
viewportBottom = viewportLayout.visibleBottom
viewportOffsetTop = viewportLayout.offsetTop
if (viewportLayout.heightLossExceedsThreshold)
this.pendingWindowScrollRepair = true
@@ -303,13 +257,6 @@ export class AdaptiveInput extends EventTarget {
viewportOffsetTop,
}
this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
if (updateViewportNow) {
if (this.synchronousHeightBeforePrediction === undefined)
this.synchronousHeightBeforePrediction = this.viewport.style.height
this.synchronousHeightValue = `${viewportBottom}px`
this.viewport.style.height = this.synchronousHeightValue
}
}
private readonly onFocusIn = (event: FocusEvent) => {
@@ -320,7 +267,7 @@ export class AdaptiveInput extends EventTarget {
return
this.focusPhase = 'focused'
this.referenceLayoutHeight = readViewportProfile(this.targetWindow).height
this.referenceLayoutHeight = readLayoutHeight(this.targetWindow)
this.requestMeasurement()
}
@@ -332,17 +279,13 @@ export class AdaptiveInput extends EventTarget {
return
this.focusPhase = 'closing'
this.predictedViewportHeight = undefined
if (this.predictionTimeout !== undefined)
this.targetWindow.clearTimeout(this.predictionTimeout)
this.predictionTimeout = undefined
// NOTICE:
// Why: The input region must follow the keyboard as soon as its owned input loses focus.
// Root cause: Safari keeps the keyboard-sized Visual Viewport until its close animation ends.
// Source: https://bugs.webkit.org/show_bug.cgi?id=265578
// Removal condition: Safari reports each intermediate keyboard close frame through a keyboard API.
const normalHeight = this.referenceLayoutHeight || readViewportProfile(this.targetWindow).height
const normalHeight = this.referenceLayoutHeight || readLayoutHeight(this.targetWindow)
this.layoutValue = {
keyboardVisible: false,
stableViewportHeight: normalHeight,
@@ -352,113 +295,11 @@ export class AdaptiveInput extends EventTarget {
}
this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
if (this.synchronousHeightBeforePrediction === undefined)
this.synchronousHeightBeforePrediction = this.viewport.style.height
if (this.synchronousHeightBeforeWrite === undefined)
this.synchronousHeightBeforeWrite = this.viewport.style.height
this.synchronousHeightValue = `${normalHeight}px`
this.viewport.style.height = this.synchronousHeightValue
this.requestMeasurement()
}
private readonly onOrientationChange = () => {
this.viewportSample = undefined
this.predictedViewportHeight = undefined
if (this.predictionTimeout !== undefined)
this.targetWindow.clearTimeout(this.predictionTimeout)
this.predictionTimeout = undefined
this.requestMeasurement()
}
private readonly onPointerDown = (event: PointerEvent) => {
if (this.virtualKeyboard || !this.visualViewport || !this.viewportSample)
return
if (event.defaultPrevented || !event.cancelable || !event.isPrimary || event.button !== 0 || event.pointerType === 'mouse')
return
if (!(event.target instanceof Element))
throw new TypeError('The pointerdown event target must be an Element.')
const editable = event.target.closest(TEXT_ENTRY_SELECTOR)
if (!editable || !this.area.contains(editable) || editable.matches(':disabled, [readonly]'))
return
if (!(editable instanceof HTMLElement))
throw new TypeError('The matched text-entry target must be an HTMLElement.')
if (this.targetWindow.document.activeElement === editable)
return
// NOTICE:
// Why: Safari must own a second touch while its software keyboard closes.
// Root cause: Canceling this touch leaves only programmatic focus, but the native dismissal can
// still finish and leave the input focused without a keyboard.
// Source/context: See the closing-focus regression in adaptive-input.test.ts.
// Removal condition: Safari exposes a keyboard lifecycle that can cancel an active dismissal.
if (this.focusPhase === 'closing')
return
const currentProfile = readViewportProfile(this.targetWindow)
const cachedHeight = calculateCachedViewportHeight(this.viewportSample, currentProfile, Date.now())
if (cachedHeight === undefined)
return
// WORKAROUND:
// NOTICE:
// Why: The input region must clear the keyboard before Safari applies its focus pan.
// Root cause: Safari decides whether to pan the page before it reports keyboard geometry.
// Source: https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API
// Code reference: https://github.com/morethanwords/tweb/blob/b21491cfdec248127cfb6a1e6617e26826021ff4/src/helpers/dom/fixSafariStickyInput.ts#L1-L23
// Removal condition: Safari provides keyboard geometry before its focus policy runs.
event.preventDefault()
this.referenceLayoutHeight = currentProfile.height
this.predictedViewportHeight = cachedHeight
this.layoutValue = {
keyboardVisible: true,
stableViewportHeight: currentProfile.height,
visibleHeight: cachedHeight,
viewportBottom: cachedHeight,
viewportOffsetTop: 0,
}
this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
if (this.synchronousHeightBeforePrediction === undefined)
this.synchronousHeightBeforePrediction = this.viewport.style.height
this.synchronousHeightValue = `${cachedHeight}px`
this.viewport.style.height = this.synchronousHeightValue
this.viewport.getBoundingClientRect()
editable.focus({ preventScroll: true })
if (this.targetWindow.document.activeElement === editable) {
if (this.predictionTimeout !== undefined)
this.targetWindow.clearTimeout(this.predictionTimeout)
// WORKAROUND:
// NOTICE:
// Why: A cached height must not keep the viewport compressed when no software keyboard opens.
// Root cause: An external keyboard produces no keyboard-sized Visual Viewport event.
// Code context: The pre-focus pointer handler applies a cached height before focus.
// Removal condition: Browsers expose keyboard visibility before focus.
this.predictionTimeout = this.targetWindow.setTimeout(() => {
this.predictedViewportHeight = undefined
this.predictionTimeout = undefined
this.requestMeasurement()
}, 1_000)
return
}
this.predictedViewportHeight = undefined
this.layoutValue = {
keyboardVisible: false,
stableViewportHeight: currentProfile.height,
visibleHeight: currentProfile.height,
viewportBottom: currentProfile.height,
viewportOffsetTop: 0,
}
this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
this.synchronousHeightValue = `${currentProfile.height}px`
this.viewport.style.height = this.synchronousHeightValue
this.requestMeasurement()
}
}
@@ -102,10 +102,10 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
// WORKAROUND:
// NOTICE:
// Why: A CSS height transition leaves the input at its old position during Safari's focus check.
// Root cause: The keyboard workaround must apply the new layout before it calls focus().
// Code context: AdaptiveInput updates the viewport element synchronously before focus.
// Removal condition: Safari provides keyboard geometry before it applies the focus scroll.
// Why: Prevent a visible jump when delayed keyboard geometry changes the input layout.
// Root cause: Safari starts its viewport pan before Visual Viewport reports the final geometry.
// Source: https://bugs.webkit.org/show_bug.cgi?id=265578
// Removal condition: Safari reports keyboard geometry before its viewport pan starts.
areaAnimation = target.animate([
{ transform: `translate3d(0, ${offset}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },