From 6697cb4c8703bf3018fa7537f78f3928ef873f37 Mon Sep 17 00:00:00 2001 From: Doji Date: Wed, 2 Sep 2026 12:21:51 +0800 Subject: [PATCH] fix(stage-layouts): fix iOS keyboard closing right after opening (#2436) --- .tool-versions | 3 +- .../browser/adaptive-input-geometry.test.ts | 111 +--------- .../src/browser/adaptive-input-geometry.ts | 64 ------ .../src/browser/adaptive-input.test.ts | 12 +- .../src/browser/adaptive-input.ts | 197 ++---------------- .../use-mobile-interactive-area-layout.ts | 8 +- 6 files changed, 32 insertions(+), 363 deletions(-) diff --git a/.tool-versions b/.tool-versions index 39c4ea76b..44a3a1ff5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ nodejs 26.7.0 -pnpm 11.24.0 +pnpm 11.24.0 +dotnet 10 diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts index 0b46a1ed3..5b251c5c6 100644 --- a/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts +++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts @@ -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 { - return { - displayMode: 'browser', - height: 714, - width: 390, - ...options, - } -} - -function createViewportSample(options: Partial = {}): 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() - }) }) diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts index 7f36ea661..1401091af 100644 --- a/packages/stage-layouts/src/browser/adaptive-input-geometry.ts +++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts @@ -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. * diff --git a/packages/stage-layouts/src/browser/adaptive-input.test.ts b/packages/stage-layouts/src/browser/adaptive-input.test.ts index 00fb7764a..0326d9fc6 100644 --- a/packages/stage-layouts/src/browser/adaptive-input.test.ts +++ b/packages/stage-layouts/src/browser/adaptive-input.test.ts @@ -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() }) diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts index 3f3cecbed..0d6a8f9ab 100644 --- a/packages/stage-layouts/src/browser/adaptive-input.ts +++ b/packages/stage-layouts/src/browser/adaptive-input.ts @@ -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() - } } diff --git a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts index ecb4cbbcd..1f756b1c2 100644 --- a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts +++ b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts @@ -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)' },