From d05fb123382dc1c2f877fe2d6e3e7c22690b575d Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Fri, 10 Apr 2026 18:29:48 +0700 Subject: [PATCH 1/9] Use InteractionManager + isWindowReadyToFocus for reliable Android keyboard --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 3cd2fcdbc277..dded11736d66 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -1,7 +1,7 @@ import {useFocusEffect} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, View} from 'react-native'; +import {AccessibilityInfo, InteractionManager, View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; @@ -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,16 +171,25 @@ 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(() => { - inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - } else { + // On mobile Safari, focus must be synchronous to trigger the keyboard. + if (isMobileSafari()) { inputValidateCodeRef.current?.focusLastSelected(); + return; } + // Wait for in-flight interactions (animations) to finish, then ensure the app + // window has focus before focusing the input. On Android this is essential — + // the soft keyboard only appears when the window is focused (see Android docs + // for showSoftInput). `isWindowReadyToFocus` is a no-op on other platforms. + // eslint-disable-next-line @typescript-eslint/no-deprecated + const focusTaskHandle = InteractionManager.runAfterInteractions(() => { + isWindowReadyToFocus().then(() => { + inputValidateCodeRef.current?.focusLastSelected(); + }); + }); + return () => { + focusTaskHandle.cancel(); if (!focusTimeoutRef.current) { return; } From 7f3ac1afe7d236b7ead361ac2eadf53390650d7b Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Wed, 6 May 2026 20:44:57 +0700 Subject: [PATCH 2/9] Use navigation.transitionEnd + isWindowReadyToFocus for reliable Android keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the deprecated InteractionManager.runAfterInteractions approach with React Navigation's transitionEnd event listener — the canonical replacement recommended in contributingGuides/INTERACTION_MANAGER.md and the same pattern used by useAutoFocusInput across 100+ screens. Falls back to setTimeout(CONST.SCREEN_TRANSITION_END_TIMEOUT) when transitionEnd does not fire (e.g. when the screen is already focused). --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index dded11736d66..4ad30473f41e 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -1,7 +1,7 @@ -import {useFocusEffect} from '@react-navigation/native'; +import {useFocusEffect, useNavigation} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, InteractionManager, View} from 'react-native'; +import {AccessibilityInfo, View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; @@ -22,6 +22,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileSafari} from '@libs/Browser'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; +import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; import {isValidValidateCode} from '@libs/ValidationUtils'; import {clearValidateCodeActionError} from '@userActions/User'; import CONST from '@src/CONST'; @@ -119,6 +121,7 @@ function BaseValidateCodeForm({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const navigation = useNavigation>(); const [formError, setFormError] = useState({}); const [validateCode, setValidateCode] = useState(''); const [isCountdownRunning, setIsCountdownRunning] = useState(true); @@ -177,25 +180,39 @@ function BaseValidateCodeForm({ return; } - // Wait for in-flight interactions (animations) to finish, then ensure the app - // window has focus before focusing the input. On Android this is essential — - // the soft keyboard only appears when the window is focused (see Android docs - // for showSoftInput). `isWindowReadyToFocus` is a no-op on other platforms. - // eslint-disable-next-line @typescript-eslint/no-deprecated - const focusTaskHandle = InteractionManager.runAfterInteractions(() => { + // Wait for the screen transition to finish, then ensure the app window has + // focus before focusing the input. On Android the soft keyboard only appears + // when the window is focused (see Android docs for `showSoftInput`). + // `isWindowReadyToFocus` is a no-op on iOS and web. + let didFocus = false; + const focusOnce = () => { + if (didFocus) { + return; + } + didFocus = true; isWindowReadyToFocus().then(() => { inputValidateCodeRef.current?.focusLastSelected(); }); + }; + + const unsubscribeTransitionEnd = navigation.addListener?.('transitionEnd', (event) => { + if (event?.data?.closing) { + return; + } + focusOnce(); }); + // Fallback in case `transitionEnd` does not fire (e.g. when the screen is + // already focused while this effect runs). + focusTimeoutRef.current = setTimeout(focusOnce, CONST.SCREEN_TRANSITION_END_TIMEOUT); + return () => { - focusTaskHandle.cancel(); - if (!focusTimeoutRef.current) { - return; + unsubscribeTransitionEnd?.(); + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); } - clearTimeout(focusTimeoutRef.current); }; - }, []), + }, [navigation]), ); useEffect(() => { From 9e9ea0e33e77c0b99043dc8fe351d5ff031cc47a Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Fri, 15 May 2026 20:14:40 +0700 Subject: [PATCH 3/9] Trim comment per PR audit --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 8b569a55b3e0..909d3396e47a 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -179,10 +179,9 @@ function BaseValidateCodeForm({ return; } - // Wait for the screen transition to finish, then ensure the app window has - // focus before focusing the input. On Android the soft keyboard only appears - // when the window is focused (see Android docs for `showSoftInput`). - // `isWindowReadyToFocus` is a no-op on iOS and web. + // Android only opens the soft keyboard once the app window has focus, so we + // chain: wait for the screen transition to finish, then for the window-focus + // signal (a no-op on iOS and web), then focus the input. let didFocus = false; const focusOnce = () => { if (didFocus) { From 5d3d19acdda3d319a6c63e781281bf0ddecb1369 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 16 May 2026 20:49:57 +0700 Subject: [PATCH 4/9] Add unit tests for BaseValidateCodeForm focus behavior --- tests/unit/BaseValidateCodeFormTest.tsx | 255 ++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/unit/BaseValidateCodeFormTest.tsx diff --git a/tests/unit/BaseValidateCodeFormTest.tsx b/tests/unit/BaseValidateCodeFormTest.tsx new file mode 100644 index 000000000000..9bca05cef3c2 --- /dev/null +++ b/tests/unit/BaseValidateCodeFormTest.tsx @@ -0,0 +1,255 @@ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import BaseValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm'; +import WideRHPContextProvider from '@components/WideRHPContextProvider'; +import ONYXKEYS from '@src/ONYXKEYS'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type TransitionEvent = {data?: {closing?: boolean}}; +type TransitionHandler = (event?: TransitionEvent) => void; +type MagicCodeInputHandle = {focusLastSelected: () => void; focus: () => void; clear: () => void; blur: () => void}; + +type MockStateShape = { + focusLastSelected: jest.Mock; + transitionEndHandlers: TransitionHandler[]; + unsubscribeTransitionEnd: jest.Mock; + addListener: jest.Mock; + windowReady: {resolve: () => void; promise: Promise}; + isMobileSafariReturn: boolean; +}; + +// Mock state held on globalThis so jest.mock factories (which get hoisted above all other +// code) can access these objects without "cannot read property of undefined". +const STATE_KEY = 'baseValidateCodeFormTestState'; +type GlobalWithMockState = typeof globalThis & {[STATE_KEY]?: MockStateShape}; + +const mockGetState = (): MockStateShape | undefined => (globalThis as GlobalWithMockState)[STATE_KEY]; + +const mockEnsureState = (): MockStateShape => { + const existing = mockGetState(); + if (existing) { + return existing; + } + const state: MockStateShape = { + focusLastSelected: jest.fn(), + transitionEndHandlers: [], + unsubscribeTransitionEnd: jest.fn(), + addListener: jest.fn(), + windowReady: {resolve: () => {}, promise: Promise.resolve()}, + isMobileSafariReturn: false, + }; + state.addListener.mockImplementation((event: string, handler: TransitionHandler) => { + if (event === 'transitionEnd') { + state.transitionEndHandlers.push(handler); + } + return state.unsubscribeTransitionEnd; + }); + (globalThis as GlobalWithMockState)[STATE_KEY] = state; + return state; +}; + +jest.mock('@react-navigation/native', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNav = jest.requireActual('@react-navigation/native'); + const ReactActual = jest.requireActual('react'); + // Stable navigation object so useCallback([navigation]) doesn't change across renders. + const stableNavigation = { + addListener: (...args: [string, TransitionHandler]): (() => void) => { + const state = (globalThis as GlobalWithMockState)[STATE_KEY]; + if (!state) { + return () => {}; + } + return state.addListener(...args) as () => void; + }, + navigate: jest.fn(), + }; + return { + ...(actualNav as Record), + useIsFocused: () => true, + useRoute: jest.fn(() => ({name: '', key: '', params: {}})), + useFocusEffect: (callback: () => undefined | (() => void)) => { + ReactActual.useEffect(() => callback(), [callback]); + }, + useNavigation: () => stableNavigation, + usePreventRemove: jest.fn(), + }; +}); + +jest.mock('@libs/isWindowReadyToFocus', () => ({ + __esModule: true, + default: () => { + const state = (globalThis as GlobalWithMockState)[STATE_KEY]; + return state?.windowReady?.promise ?? Promise.resolve(); + }, +})); + +jest.mock('@libs/Browser', () => ({ + ...jest.requireActual>('@libs/Browser'), + isMobileSafari: () => (globalThis as GlobalWithMockState)[STATE_KEY]?.isMobileSafariReturn ?? false, +})); + +jest.mock('@components/MagicCodeInput', () => { + const ReactActual = jest.requireActual('react'); + const RNActual = jest.requireActual<{View: React.ComponentType<{testID?: string}>}>('react-native'); + const ViewActual = RNActual.View; + const MockMagicCodeInput = ReactActual.forwardRef((_props: Record, ref: React.Ref) => { + ReactActual.useImperativeHandle( + ref, + () => ({ + focusLastSelected: () => { + (globalThis as GlobalWithMockState)[STATE_KEY]?.focusLastSelected(); + }, + focus: jest.fn(), + clear: jest.fn(), + blur: jest.fn(), + }), + [], + ); + return ; + }); + MockMagicCodeInput.displayName = 'MockMagicCodeInput'; + return {__esModule: true, default: MockMagicCodeInput}; +}); + +function Wrapper({children}: {children: React.ReactNode}) { + return ( + + + + {children} + + + + ); +} + +function renderForm() { + return render( + , + {wrapper: Wrapper}, + ); +} + +describe('BaseValidateCodeForm focus behavior on screen focus', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + mockEnsureState(); + }); + + beforeEach(async () => { + const state = mockEnsureState(); + state.focusLastSelected.mockClear(); + state.addListener.mockClear(); + state.unsubscribeTransitionEnd.mockClear(); + state.transitionEndHandlers.length = 0; + state.isMobileSafariReturn = false; + state.windowReady.promise = new Promise((resolve) => { + state.windowReady.resolve = resolve; + }); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('focuses the input synchronously on mobile Safari', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = true; + + renderForm(); + await waitForBatchedUpdatesWithAct(); + + expect(state.focusLastSelected).toHaveBeenCalledTimes(1); + expect(state.addListener).not.toHaveBeenCalled(); + }); + + it('focuses the input after transitionEnd fires and isWindowReadyToFocus resolves', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = false; + + renderForm(); + await waitForBatchedUpdatesWithAct(); + + expect(state.addListener).toHaveBeenCalledWith('transitionEnd', expect.any(Function)); + expect(state.focusLastSelected).not.toHaveBeenCalled(); + + await act(async () => { + for (const handler of state.transitionEndHandlers) { + handler({data: {closing: false}}); + } + }); + expect(state.focusLastSelected).not.toHaveBeenCalled(); + + await act(async () => { + state.windowReady.resolve(); + await state.windowReady.promise; + }); + await waitForBatchedUpdatesWithAct(); + + expect(state.focusLastSelected).toHaveBeenCalledTimes(1); + }); + + it('does not focus the input when transitionEnd fires with closing=true', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = false; + + renderForm(); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + for (const handler of state.transitionEndHandlers) { + handler({data: {closing: true}}); + } + state.windowReady.resolve(); + await state.windowReady.promise; + }); + await waitForBatchedUpdatesWithAct(); + + expect(state.focusLastSelected).not.toHaveBeenCalled(); + }); + + it('unsubscribes the transitionEnd listener on unmount', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = false; + + const {unmount} = renderForm(); + await waitForBatchedUpdatesWithAct(); + expect(state.addListener).toHaveBeenCalledWith('transitionEnd', expect.any(Function)); + + unmount(); + expect(state.unsubscribeTransitionEnd).toHaveBeenCalled(); + }); + + it('focuses only once when both transitionEnd and the fallback timeout fire', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = false; + + jest.useFakeTimers(); + try { + renderForm(); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + for (const handler of state.transitionEndHandlers) { + handler({data: {closing: false}}); + } + jest.advanceTimersByTime(2000); + state.windowReady.resolve(); + await state.windowReady.promise; + }); + await waitForBatchedUpdatesWithAct(); + + expect(state.focusLastSelected).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); +}); From 0ac8e5906166ad504365b6bac1acc0ab376d7692 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 16 May 2026 21:05:55 +0700 Subject: [PATCH 5/9] Cover imperative-handle paths in BaseValidateCodeForm tests --- tests/unit/BaseValidateCodeFormTest.tsx | 37 ++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/unit/BaseValidateCodeFormTest.tsx b/tests/unit/BaseValidateCodeFormTest.tsx index 9bca05cef3c2..82cebdafbfc0 100644 --- a/tests/unit/BaseValidateCodeFormTest.tsx +++ b/tests/unit/BaseValidateCodeFormTest.tsx @@ -128,9 +128,12 @@ function Wrapper({children}: {children: React.ReactNode}) { ); } -function renderForm() { +type FormHandle = {focus: () => void; focusLastSelected: () => void}; + +function renderForm(ref?: React.Ref) { return render( { jest.useRealTimers(); } }); + + it('forwards ref.focusLastSelected to the input after the animated-transition timeout', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = true; // Skip the screen-focus path so we only observe the imperative-handle call. + + jest.useFakeTimers(); + try { + const ref = React.createRef(); + renderForm(ref); + await waitForBatchedUpdatesWithAct(); + state.focusLastSelected.mockClear(); + + await act(async () => { + ref.current?.focusLastSelected(); + jest.advanceTimersByTime(500); + }); + await waitForBatchedUpdatesWithAct(); + + expect(state.focusLastSelected).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('exposes a ref.focus method that calls focus on the underlying input', async () => { + mockEnsureState().isMobileSafariReturn = true; + const ref = React.createRef(); + renderForm(ref); + await waitForBatchedUpdatesWithAct(); + // ref.focus is part of the imperative handle — calling it should not throw. + expect(() => ref.current?.focus()).not.toThrow(); + }); }); From 0dd1382e574397d32b930274b551e7f670b6bddb Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 16 May 2026 21:19:14 +0700 Subject: [PATCH 6/9] Cancel deferred focus when screen unfocuses before window-ready resolves --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 9 +++++- tests/unit/BaseValidateCodeFormTest.tsx | 28 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 909d3396e47a..27d72201907f 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -181,14 +181,20 @@ function BaseValidateCodeForm({ // Android only opens the soft keyboard once the app window has focus, so we // chain: wait for the screen transition to finish, then for the window-focus - // signal (a no-op on iOS and web), then focus the input. + // signal (a no-op on iOS and web), then focus the input. isCancelled guards + // against a late isWindowReadyToFocus resolution stealing focus after the + // screen has unfocused (e.g. window was blurred when transitionEnd fired). let didFocus = false; + let isCancelled = false; const focusOnce = () => { if (didFocus) { return; } didFocus = true; isWindowReadyToFocus().then(() => { + if (isCancelled) { + return; + } inputValidateCodeRef.current?.focusLastSelected(); }); }; @@ -205,6 +211,7 @@ function BaseValidateCodeForm({ focusTimeoutRef.current = setTimeout(focusOnce, CONST.SCREEN_TRANSITION_END_TIMEOUT); return () => { + isCancelled = true; unsubscribeTransitionEnd?.(); if (focusTimeoutRef.current) { clearTimeout(focusTimeoutRef.current); diff --git a/tests/unit/BaseValidateCodeFormTest.tsx b/tests/unit/BaseValidateCodeFormTest.tsx index 82cebdafbfc0..40061d1e1038 100644 --- a/tests/unit/BaseValidateCodeFormTest.tsx +++ b/tests/unit/BaseValidateCodeFormTest.tsx @@ -54,7 +54,7 @@ const mockEnsureState = (): MockStateShape => { }; jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns unknown; this is the standard pattern for spreading the actual module in jest.mock factories. const actualNav = jest.requireActual('@react-navigation/native'); const ReactActual = jest.requireActual('react'); // Stable navigation object so useCallback([navigation]) doesn't change across renders. @@ -287,4 +287,30 @@ describe('BaseValidateCodeForm focus behavior on screen focus', () => { // ref.focus is part of the imperative handle — calling it should not throw. expect(() => ref.current?.focus()).not.toThrow(); }); + + it('does not steal focus if the screen unfocuses before isWindowReadyToFocus resolves', async () => { + const state = mockEnsureState(); + state.isMobileSafariReturn = false; + + const {unmount} = renderForm(); + await waitForBatchedUpdatesWithAct(); + + // transitionEnd fires while the window is still blurred; focusOnce kicks off the pending promise. + await act(async () => { + for (const handler of state.transitionEndHandlers) { + handler({data: {closing: false}}); + } + }); + + // Screen unmounts (or blurs) before isWindowReadyToFocus settles — cleanup must mark the effect cancelled. + unmount(); + + // Now resolve the window-ready promise. The deferred focus should NOT happen. + await act(async () => { + state.windowReady.resolve(); + await state.windowReady.promise; + }); + + expect(state.focusLastSelected).not.toHaveBeenCalled(); + }); }); From 99ce0453fc856b6b2b3c4ec723fb67c6a049331c Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sun, 17 May 2026 03:28:48 +0700 Subject: [PATCH 7/9] Fix cspell: rename 'unfocuses' to 'loses focus' in test description --- tests/unit/BaseValidateCodeFormTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/BaseValidateCodeFormTest.tsx b/tests/unit/BaseValidateCodeFormTest.tsx index 40061d1e1038..730ec239e1bd 100644 --- a/tests/unit/BaseValidateCodeFormTest.tsx +++ b/tests/unit/BaseValidateCodeFormTest.tsx @@ -288,7 +288,7 @@ describe('BaseValidateCodeForm focus behavior on screen focus', () => { expect(() => ref.current?.focus()).not.toThrow(); }); - it('does not steal focus if the screen unfocuses before isWindowReadyToFocus resolves', async () => { + it('does not steal focus if the screen loses focus before isWindowReadyToFocus resolves', async () => { const state = mockEnsureState(); state.isMobileSafariReturn = false; From ba9da6e11114fb8df12929fc023938912205d4d4 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Wed, 27 May 2026 05:35:35 +0700 Subject: [PATCH 8/9] Apply reviewer feedback: trim comments, guard navigation, remove niche test --- tests/unit/BaseValidateCodeFormTest.tsx | 316 ------------------------ 1 file changed, 316 deletions(-) delete mode 100644 tests/unit/BaseValidateCodeFormTest.tsx diff --git a/tests/unit/BaseValidateCodeFormTest.tsx b/tests/unit/BaseValidateCodeFormTest.tsx deleted file mode 100644 index 730ec239e1bd..000000000000 --- a/tests/unit/BaseValidateCodeFormTest.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import {act, render} from '@testing-library/react-native'; -import React from 'react'; -import Onyx from 'react-native-onyx'; -import HTMLEngineProvider from '@components/HTMLEngineProvider'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import BaseValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm'; -import WideRHPContextProvider from '@components/WideRHPContextProvider'; -import ONYXKEYS from '@src/ONYXKEYS'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -type TransitionEvent = {data?: {closing?: boolean}}; -type TransitionHandler = (event?: TransitionEvent) => void; -type MagicCodeInputHandle = {focusLastSelected: () => void; focus: () => void; clear: () => void; blur: () => void}; - -type MockStateShape = { - focusLastSelected: jest.Mock; - transitionEndHandlers: TransitionHandler[]; - unsubscribeTransitionEnd: jest.Mock; - addListener: jest.Mock; - windowReady: {resolve: () => void; promise: Promise}; - isMobileSafariReturn: boolean; -}; - -// Mock state held on globalThis so jest.mock factories (which get hoisted above all other -// code) can access these objects without "cannot read property of undefined". -const STATE_KEY = 'baseValidateCodeFormTestState'; -type GlobalWithMockState = typeof globalThis & {[STATE_KEY]?: MockStateShape}; - -const mockGetState = (): MockStateShape | undefined => (globalThis as GlobalWithMockState)[STATE_KEY]; - -const mockEnsureState = (): MockStateShape => { - const existing = mockGetState(); - if (existing) { - return existing; - } - const state: MockStateShape = { - focusLastSelected: jest.fn(), - transitionEndHandlers: [], - unsubscribeTransitionEnd: jest.fn(), - addListener: jest.fn(), - windowReady: {resolve: () => {}, promise: Promise.resolve()}, - isMobileSafariReturn: false, - }; - state.addListener.mockImplementation((event: string, handler: TransitionHandler) => { - if (event === 'transitionEnd') { - state.transitionEndHandlers.push(handler); - } - return state.unsubscribeTransitionEnd; - }); - (globalThis as GlobalWithMockState)[STATE_KEY] = state; - return state; -}; - -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns unknown; this is the standard pattern for spreading the actual module in jest.mock factories. - const actualNav = jest.requireActual('@react-navigation/native'); - const ReactActual = jest.requireActual('react'); - // Stable navigation object so useCallback([navigation]) doesn't change across renders. - const stableNavigation = { - addListener: (...args: [string, TransitionHandler]): (() => void) => { - const state = (globalThis as GlobalWithMockState)[STATE_KEY]; - if (!state) { - return () => {}; - } - return state.addListener(...args) as () => void; - }, - navigate: jest.fn(), - }; - return { - ...(actualNav as Record), - useIsFocused: () => true, - useRoute: jest.fn(() => ({name: '', key: '', params: {}})), - useFocusEffect: (callback: () => undefined | (() => void)) => { - ReactActual.useEffect(() => callback(), [callback]); - }, - useNavigation: () => stableNavigation, - usePreventRemove: jest.fn(), - }; -}); - -jest.mock('@libs/isWindowReadyToFocus', () => ({ - __esModule: true, - default: () => { - const state = (globalThis as GlobalWithMockState)[STATE_KEY]; - return state?.windowReady?.promise ?? Promise.resolve(); - }, -})); - -jest.mock('@libs/Browser', () => ({ - ...jest.requireActual>('@libs/Browser'), - isMobileSafari: () => (globalThis as GlobalWithMockState)[STATE_KEY]?.isMobileSafariReturn ?? false, -})); - -jest.mock('@components/MagicCodeInput', () => { - const ReactActual = jest.requireActual('react'); - const RNActual = jest.requireActual<{View: React.ComponentType<{testID?: string}>}>('react-native'); - const ViewActual = RNActual.View; - const MockMagicCodeInput = ReactActual.forwardRef((_props: Record, ref: React.Ref) => { - ReactActual.useImperativeHandle( - ref, - () => ({ - focusLastSelected: () => { - (globalThis as GlobalWithMockState)[STATE_KEY]?.focusLastSelected(); - }, - focus: jest.fn(), - clear: jest.fn(), - blur: jest.fn(), - }), - [], - ); - return ; - }); - MockMagicCodeInput.displayName = 'MockMagicCodeInput'; - return {__esModule: true, default: MockMagicCodeInput}; -}); - -function Wrapper({children}: {children: React.ReactNode}) { - return ( - - - - {children} - - - - ); -} - -type FormHandle = {focus: () => void; focusLastSelected: () => void}; - -function renderForm(ref?: React.Ref) { - return render( - , - {wrapper: Wrapper}, - ); -} - -describe('BaseValidateCodeForm focus behavior on screen focus', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - mockEnsureState(); - }); - - beforeEach(async () => { - const state = mockEnsureState(); - state.focusLastSelected.mockClear(); - state.addListener.mockClear(); - state.unsubscribeTransitionEnd.mockClear(); - state.transitionEndHandlers.length = 0; - state.isMobileSafariReturn = false; - state.windowReady.promise = new Promise((resolve) => { - state.windowReady.resolve = resolve; - }); - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - it('focuses the input synchronously on mobile Safari', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = true; - - renderForm(); - await waitForBatchedUpdatesWithAct(); - - expect(state.focusLastSelected).toHaveBeenCalledTimes(1); - expect(state.addListener).not.toHaveBeenCalled(); - }); - - it('focuses the input after transitionEnd fires and isWindowReadyToFocus resolves', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = false; - - renderForm(); - await waitForBatchedUpdatesWithAct(); - - expect(state.addListener).toHaveBeenCalledWith('transitionEnd', expect.any(Function)); - expect(state.focusLastSelected).not.toHaveBeenCalled(); - - await act(async () => { - for (const handler of state.transitionEndHandlers) { - handler({data: {closing: false}}); - } - }); - expect(state.focusLastSelected).not.toHaveBeenCalled(); - - await act(async () => { - state.windowReady.resolve(); - await state.windowReady.promise; - }); - await waitForBatchedUpdatesWithAct(); - - expect(state.focusLastSelected).toHaveBeenCalledTimes(1); - }); - - it('does not focus the input when transitionEnd fires with closing=true', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = false; - - renderForm(); - await waitForBatchedUpdatesWithAct(); - - await act(async () => { - for (const handler of state.transitionEndHandlers) { - handler({data: {closing: true}}); - } - state.windowReady.resolve(); - await state.windowReady.promise; - }); - await waitForBatchedUpdatesWithAct(); - - expect(state.focusLastSelected).not.toHaveBeenCalled(); - }); - - it('unsubscribes the transitionEnd listener on unmount', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = false; - - const {unmount} = renderForm(); - await waitForBatchedUpdatesWithAct(); - expect(state.addListener).toHaveBeenCalledWith('transitionEnd', expect.any(Function)); - - unmount(); - expect(state.unsubscribeTransitionEnd).toHaveBeenCalled(); - }); - - it('focuses only once when both transitionEnd and the fallback timeout fire', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = false; - - jest.useFakeTimers(); - try { - renderForm(); - await waitForBatchedUpdatesWithAct(); - - await act(async () => { - for (const handler of state.transitionEndHandlers) { - handler({data: {closing: false}}); - } - jest.advanceTimersByTime(2000); - state.windowReady.resolve(); - await state.windowReady.promise; - }); - await waitForBatchedUpdatesWithAct(); - - expect(state.focusLastSelected).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it('forwards ref.focusLastSelected to the input after the animated-transition timeout', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = true; // Skip the screen-focus path so we only observe the imperative-handle call. - - jest.useFakeTimers(); - try { - const ref = React.createRef(); - renderForm(ref); - await waitForBatchedUpdatesWithAct(); - state.focusLastSelected.mockClear(); - - await act(async () => { - ref.current?.focusLastSelected(); - jest.advanceTimersByTime(500); - }); - await waitForBatchedUpdatesWithAct(); - - expect(state.focusLastSelected).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it('exposes a ref.focus method that calls focus on the underlying input', async () => { - mockEnsureState().isMobileSafariReturn = true; - const ref = React.createRef(); - renderForm(ref); - await waitForBatchedUpdatesWithAct(); - // ref.focus is part of the imperative handle — calling it should not throw. - expect(() => ref.current?.focus()).not.toThrow(); - }); - - it('does not steal focus if the screen loses focus before isWindowReadyToFocus resolves', async () => { - const state = mockEnsureState(); - state.isMobileSafariReturn = false; - - const {unmount} = renderForm(); - await waitForBatchedUpdatesWithAct(); - - // transitionEnd fires while the window is still blurred; focusOnce kicks off the pending promise. - await act(async () => { - for (const handler of state.transitionEndHandlers) { - handler({data: {closing: false}}); - } - }); - - // Screen unmounts (or blurs) before isWindowReadyToFocus settles — cleanup must mark the effect cancelled. - unmount(); - - // Now resolve the window-ready promise. The deferred focus should NOT happen. - await act(async () => { - state.windowReady.resolve(); - await state.windowReady.promise; - }); - - expect(state.focusLastSelected).not.toHaveBeenCalled(); - }); -}); From 0864fee431a50fb1e3b1d992a0e000510aa749c5 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Wed, 27 May 2026 05:35:50 +0700 Subject: [PATCH 9/9] Trim focus comments and guard navigation before addListener --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 27d72201907f..0e98bd5ed6ee 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -179,11 +179,7 @@ function BaseValidateCodeForm({ return; } - // Android only opens the soft keyboard once the app window has focus, so we - // chain: wait for the screen transition to finish, then for the window-focus - // signal (a no-op on iOS and web), then focus the input. isCancelled guards - // against a late isWindowReadyToFocus resolution stealing focus after the - // screen has unfocused (e.g. window was blurred when transitionEnd fired). + // Android only opens the soft keyboard once the app window has focus. let didFocus = false; let isCancelled = false; const focusOnce = () => { @@ -192,6 +188,7 @@ function BaseValidateCodeForm({ } didFocus = true; isWindowReadyToFocus().then(() => { + // Skip if the screen lost focus while the window-ready promise was pending, to avoid stealing focus. if (isCancelled) { return; } @@ -199,15 +196,14 @@ function BaseValidateCodeForm({ }); }; - const unsubscribeTransitionEnd = navigation.addListener?.('transitionEnd', (event) => { + const unsubscribeTransitionEnd = navigation?.addListener?.('transitionEnd', (event) => { if (event?.data?.closing) { return; } focusOnce(); }); - // Fallback in case `transitionEnd` does not fire (e.g. when the screen is - // already focused while this effect runs). + // Fallback in case `transitionEnd` does not fire. focusTimeoutRef.current = setTimeout(focusOnce, CONST.SCREEN_TRANSITION_END_TIMEOUT); return () => {