From dfa6430d99ffbb9d22cd6aae7e2e77a1827cd6e3 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:22:50 +0000 Subject: [PATCH] fix(mobile): keep the Android composer focused when the keyboard switches to a voice IME (#41209) A keyboard's mic button switches to a separate voice IME, which closes the keyboard and opens the voice panel about 250 ms later. The page read every Android keyboard close as Back and blurred the field at once, so the voice IME's text had nowhere to go. Blur only once the keyboard stays closed for a second; a reopen within that window keeps focus. --- .../platform/keyboard-close-blur.web.test.ts | 18 ++++++++++++++++-- mobile/src/platform/keyboard-close-blur.web.ts | 14 +++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/mobile/src/platform/keyboard-close-blur.web.test.ts b/mobile/src/platform/keyboard-close-blur.web.test.ts index 62b14b1021f..52de9babec7 100644 --- a/mobile/src/platform/keyboard-close-blur.web.test.ts +++ b/mobile/src/platform/keyboard-close-blur.web.test.ts @@ -37,7 +37,7 @@ function focusedField(userAgent: string): HTMLInputElement { describe('a keyboard the phone closes', () => { beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'performance'] }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) publishShellKeyboardSource({ read: () => shellHeight, subscribe: (listener) => { @@ -62,10 +62,24 @@ describe('a keyboard the phone closes', () => { expect(document.activeElement).toBe(field) shellReports(0) + vi.advanceTimersByTime(1000) expect(document.activeElement).not.toBe(field) }) + it('keeps the field focused when the keyboard closes and reopens for a switch to a voice IME', () => { + const field = focusedField(ANDROID) + startBlurringOnKeyboardClose() + shellReports(300) + + shellReports(0) + vi.advanceTimersByTime(250) + shellReports(200) + vi.advanceTimersByTime(5000) + + expect(document.activeElement).toBe(field) + }) + // The second case is tapping the underlined word after Back: that tap both focuses the field and opens the menu. it.each([ ['already focused', false], @@ -105,7 +119,7 @@ describe('a keyboard the phone closes', () => { target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) vi.advanceTimersByTime(waitMs) shellReports(0) - vi.advanceTimersByTime(100) + vi.advanceTimersByTime(1000) expect(document.activeElement).not.toBe(field) }) diff --git a/mobile/src/platform/keyboard-close-blur.web.ts b/mobile/src/platform/keyboard-close-blur.web.ts index 2611184750e..54087c6929e 100644 --- a/mobile/src/platform/keyboard-close-blur.web.ts +++ b/mobile/src/platform/keyboard-close-blur.web.ts @@ -5,6 +5,8 @@ import { subscribeSoftKeyboard } from './keyboard-occlusion.web' // Chromium hides the IME itself to show a tapped autocorrected word's menu; Back comes with no touch. const FIELD_TAP_HIDE_WINDOW_MS = 1000 const KEYBOARD_REOPEN_DELAY_MS = 50 +// A keyboard's mic switches to a voice IME, which closes the keyboard and opens again ~250 ms later; Back opens none. +const IME_SWITCH_WINDOW_MS = 1000 // Android raises the IME only for a new focus session, as for the terminal live input. function reopenKeyboard(field: HTMLElement): void { @@ -14,12 +16,13 @@ function reopenKeyboard(field: HTMLElement): void { } } -// Android Back hides the IME without blurring; blur now so the next terminal touch cannot reopen it. +// Android Back hides the IME without blurring; blur once it stays closed so the next terminal touch cannot reopen it. export function startBlurringOnKeyboardClose(): void { if (hostOs() !== 'android') { return } let lastTouch: { target: Node; at: number } | null = null + let pendingBlur: ReturnType | undefined document.addEventListener( 'pointerdown', (event) => { @@ -29,7 +32,7 @@ export function startBlurringOnKeyboardClose(): void { { capture: true } ) subscribeSoftKeyboard( - () => {}, + () => clearTimeout(pendingBlur), () => { const field = document.activeElement const touch = lastTouch @@ -42,7 +45,12 @@ export function startBlurringOnKeyboardClose(): void { ) { setTimeout(() => reopenKeyboard(field), KEYBOARD_REOPEN_DELAY_MS) } else { - Keyboard.dismiss() + // Leave alone a field focus moved to meanwhile. + pendingBlur = setTimeout(() => { + if (document.activeElement === field) { + Keyboard.dismiss() + } + }, IME_SWITCH_WINDOW_MS) } } )