From d508dce146d9c95b4cd688681730fce907be4945 Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Mon, 16 Mar 2026 12:07:39 +0000 Subject: [PATCH] Prevent keyboard from appearing over splash screen on iOS app reopen Add iOS-specific isWindowReadyToFocus implementation that blocks focus operations during background-to-foreground transitions, add global Keyboard.dismiss() on iOS foreground transition, and gate Magic Code input focus with isWindowReadyToFocus. Co-authored-by: Aimane Chnaif --- src/Expensify.tsx | 19 +++++++++-- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 19 +++++++---- src/libs/isWindowReadyToFocus/index.ios.ts | 33 +++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/libs/isWindowReadyToFocus/index.ios.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 0afde4a09222..22938e302969 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -1,8 +1,8 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import type * as Sentry from '@sentry/react-native'; import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; -import type {NativeEventSubscription} from 'react-native'; -import {AppState, Platform} from 'react-native'; +import type {AppStateStatus, NativeEventSubscription} from 'react-native'; +import {AppState, Keyboard, Platform} from 'react-native'; import Onyx from 'react-native-onyx'; import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; @@ -57,6 +57,8 @@ Onyx.registerLogger(({level, message, parameters}) => { function Expensify() { const appStateChangeListener = useRef(null); + const keyboardDismissListener = useRef(null); + const previousAppState = useRef(AppState.currentState); const [isNavigationReady, setIsNavigationReady] = useState(false); const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); const {splashScreenState} = useSplashScreenState(); @@ -243,6 +245,18 @@ function Expensify() { appStateChangeListener.current = AppState.addEventListener('change', initializeClient); + // On iOS, dismiss the keyboard when returning from background to prevent it from + // appearing over the splash/transition screen. iOS natively restores first responder + // status on previously focused TextInputs when returning from background. + if (Platform.OS === 'ios') { + keyboardDismissListener.current = AppState.addEventListener('change', (nextAppState) => { + if ((previousAppState.current === 'inactive' || previousAppState.current === 'background') && nextAppState === 'active') { + Keyboard.dismiss(); + } + previousAppState.current = nextAppState; + }); + } + setIsAuthenticatedAtStartup(isAuthenticated); startSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK, { @@ -257,6 +271,7 @@ function Expensify() { return () => { appStateChangeListener.current?.remove(); + keyboardDismissListener.current?.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 3cd2fcdbc277..89eebe35aef3 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -21,6 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileSafari} from '@libs/Browser'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; +import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import {isValidValidateCode} from '@libs/ValidationUtils'; import {clearValidateCodeActionError} from '@userActions/User'; import CONST from '@src/CONST'; @@ -170,14 +171,18 @@ function BaseValidateCodeForm({ clearTimeout(focusTimeoutRef.current); } - // Keyboard won't show if we focus the input with a delay, so we need to focus immediately. - if (!isMobileSafari()) { - focusTimeoutRef.current = setTimeout(() => { + // Wait until the window is ready to focus (blocks during iOS background-to-foreground transition) + // before triggering keyboard focus, preventing keyboard from appearing over the splash screen. + isWindowReadyToFocus().then(() => { + // Keyboard won't show if we focus the input with a delay, so we need to focus immediately. + if (!isMobileSafari()) { + focusTimeoutRef.current = setTimeout(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }, CONST.ANIMATED_TRANSITION); + } else { inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - } else { - inputValidateCodeRef.current?.focusLastSelected(); - } + } + }); return () => { if (!focusTimeoutRef.current) { diff --git a/src/libs/isWindowReadyToFocus/index.ios.ts b/src/libs/isWindowReadyToFocus/index.ios.ts new file mode 100644 index 000000000000..784e603d3688 --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.ios.ts @@ -0,0 +1,33 @@ +import {AppState} from 'react-native'; + +let isWindowReadyPromise = Promise.resolve(); +let resolveWindowReadyToFocus: (() => void) | undefined; + +AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'active') { + if (resolveWindowReadyToFocus) { + resolveWindowReadyToFocus(); + resolveWindowReadyToFocus = undefined; + } + return; + } + + // When transitioning to background or inactive, block focus until active again + if (nextAppState === 'background' || nextAppState === 'inactive') { + if (!resolveWindowReadyToFocus) { + isWindowReadyPromise = new Promise((resolve) => { + resolveWindowReadyToFocus = resolve; + }); + } + } +}); + +/** + * On iOS, we need to ensure that the app is fully active before focusing an input. + * When the app transitions from background/inactive to active, iOS may restore the first responder + * and show the keyboard before the app finishes its transition animation. + * This function blocks focus operations until the app is fully active. + */ +const isWindowReadyToFocus = () => isWindowReadyPromise; + +export default isWindowReadyToFocus;