From 4803f1c16f6cdccef9ac70a459b230a9e05149cf Mon Sep 17 00:00:00 2001 From: Neko Date: Wed, 26 Aug 2026 15:13:28 +0800 Subject: [PATCH] fix(stage-layouts): preserve Safari focus during keyboard close (#2367) --- .../src/browser/adaptive-input.test.ts | 197 ++++++++++++++++++ .../src/browser/adaptive-input.ts | 9 + 2 files changed, 206 insertions(+) create mode 100644 packages/stage-layouts/src/browser/adaptive-input.test.ts diff --git a/packages/stage-layouts/src/browser/adaptive-input.test.ts b/packages/stage-layouts/src/browser/adaptive-input.test.ts new file mode 100644 index 000000000..00fb7764a --- /dev/null +++ b/packages/stage-layouts/src/browser/adaptive-input.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { AdaptiveInput } from './adaptive-input' + +interface TestViewport extends EventTarget { + height: number + offsetTop: number + pageTop: number +} + +function createPointerDown(): PointerEvent { + return new PointerEvent('pointerdown', { + button: 0, + bubbles: true, + cancelable: true, + isPrimary: true, + pointerType: 'touch', + }) +} + +describe('adaptive input', () => { + let animationFrames: Map + let keyboardViewportHeight: number + let layoutHeight: number + let nextAnimationFrameId: number + let targetDocument: Document + let targetWindow: Window + let visualViewport: TestViewport + + function runAnimationFrame() { + const callbacks = [...animationFrames.values()] + animationFrames.clear() + + for (const callback of callbacks) + callback(performance.now()) + } + + beforeEach(() => { + animationFrames = new Map() + layoutHeight = 714 + keyboardViewportHeight = layoutHeight - 310 + nextAnimationFrameId = 0 + visualViewport = Object.assign(new EventTarget(), { + height: layoutHeight, + offsetTop: 0, + pageTop: 0, + }) + + const targetNavigator = Object.create(window.navigator) + Object.defineProperty(targetNavigator, 'virtualKeyboard', { + configurable: true, + value: undefined, + }) + + targetDocument = Object.create(document) + Object.defineProperties(targetDocument, { + activeElement: { + configurable: true, + get: () => document.activeElement, + }, + addEventListener: { + configurable: true, + value: document.addEventListener.bind(document), + }, + documentElement: { + configurable: true, + value: { + clientHeight: layoutHeight, + clientWidth: 390, + }, + }, + }) + + targetWindow = Object.create(window) + Object.defineProperties(targetWindow, { + cancelAnimationFrame: { + configurable: true, + value: (id: number) => animationFrames.delete(id), + }, + clearTimeout: { + configurable: true, + value: window.clearTimeout.bind(window), + }, + document: { + configurable: true, + value: targetDocument, + }, + matchMedia: { + configurable: true, + value: (query: string): MediaQueryList => ({ + addEventListener: () => undefined, + addListener: () => undefined, + dispatchEvent: () => true, + matches: false, + media: query, + onchange: null, + removeEventListener: () => undefined, + removeListener: () => undefined, + }), + }, + navigator: { + configurable: true, + value: targetNavigator, + }, + requestAnimationFrame: { + configurable: true, + value: (callback: FrameRequestCallback) => { + const id = ++nextAnimationFrameId + animationFrames.set(id, callback) + return id + }, + }, + setTimeout: { + configurable: true, + value: window.setTimeout.bind(window), + }, + visualViewport: { + configurable: true, + value: visualViewport, + }, + }) + }) + + afterEach(() => { + document.body.replaceChildren() + }) + + it('keeps the native touch activation while Safari closes the keyboard', () => { + // ROOT CAUSE: + // + // Safari can finish a software keyboard dismissal after a second pointerdown focuses the input. + // Canceling that pointerdown leaves only programmatic focus, which cannot cancel the native + // dismissal and leaves the focused input without a keyboard. + // + // Before the fix, the cached pre-layout path canceled every matching pointerdown. + // + // We fixed this by preserving native activation until the closing viewport becomes stable. + const viewport = document.createElement('div') + const area = document.createElement('div') + const textarea = document.createElement('textarea') + area.append(textarea) + viewport.append(area) + document.body.append(viewport) + + const adaptiveInput = new AdaptiveInput({ area, viewport, window: targetWindow }) + runAnimationFrame() + + textarea.focus() + visualViewport.height = keyboardViewportHeight + visualViewport.dispatchEvent(new Event('resize')) + runAnimationFrame() + textarea.blur() + + const pointerDown = createPointerDown() + const nativeActivationContinues = textarea.dispatchEvent(pointerDown) + + 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() + }) + + it('uses cached pre-layout after the closing viewport becomes stable', () => { + const viewport = document.createElement('div') + const area = document.createElement('div') + const textarea = document.createElement('textarea') + area.append(textarea) + viewport.append(area) + document.body.append(viewport) + + const adaptiveInput = new AdaptiveInput({ area, viewport, window: targetWindow }) + runAnimationFrame() + + textarea.focus() + visualViewport.height = keyboardViewportHeight + visualViewport.dispatchEvent(new Event('resize')) + runAnimationFrame() + textarea.blur() + visualViewport.height = layoutHeight + visualViewport.dispatchEvent(new Event('resize')) + runAnimationFrame() + + 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`) + + adaptiveInput.dispose() + }) +}) diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts index 39beaf447..b5b7c2529 100644 --- a/packages/stage-layouts/src/browser/adaptive-input.ts +++ b/packages/stage-layouts/src/browser/adaptive-input.ts @@ -377,6 +377,15 @@ export class AdaptiveInput extends EventTarget { 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)