Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/MagicCodeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ type MagicCodeInputProps = {
/** Accessibility label for the input */
accessibilityLabel?: string;

/** Optional: receives the native text input ref so callers can run shared autofocus logic */
inputCallbackRef?: (ref: BaseTextInputRef | null) => void;

/** Reference to the outer element */
ref?: ForwardedRef<MagicCodeInputHandle>;

Expand Down Expand Up @@ -159,6 +162,7 @@ function MagicCodeInput({
hasError = false,
testID = '',
accessibilityLabel,
inputCallbackRef,
ref,
secureTextEntry = false,
}: MagicCodeInputProps) {
Expand Down Expand Up @@ -477,6 +481,8 @@ function MagicCodeInput({
}}
ref={(newRef) => {
inputRef.current = newRef;
// Notify parent/shared hook about the native TextInput ref (optional).
inputCallbackRef?.(newRef);
}}
autoFocus={autoFocus}
inputMode="numeric"
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {InteractionManager, AccessibilityInfo, View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import Button from '@components/Button';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
Expand All @@ -13,13 +13,15 @@ import Text from '@components/Text';
import ValidateCodeCountdown from '@components/ValidateCodeCountdown';
import type {ValidateCodeCountdownHandle} from '@components/ValidateCodeCountdown/types';
import {useWideRHPState} from '@components/WideRHPContextProvider';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {isMobileSafari} from '@libs/Browser';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils';
import {isValidValidateCode} from '@libs/ValidationUtils';
import {clearValidateCodeActionError} from '@userActions/User';
Expand Down Expand Up @@ -121,15 +123,19 @@ function BaseValidateCodeForm({
const [formError, setFormError] = useState<ValidateCodeFormError>({});
const [validateCode, setValidateCode] = useState('');
const [isCountdownRunning, setIsCountdownRunning] = useState(true);
const [focusCycle, setFocusCycle] = useState(0);

const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null);
const [account = getEmptyObject<Account>()] = useOnyx(ONYXKEYS.ACCOUNT);
const {inputCallbackRef, inputRef} = useAutoFocusInput(false);
const [account = getEmptyObject<Account>()] = useOnyx(ONYXKEYS.ACCOUNT, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-3 (docs)

useAutoFocusInput already provides comprehensive focus management internally -- it tracks screen transitions via transitionEnd listener, uses InteractionManager.runAfterInteractions, handles splash screen state, popover visibility, and side panel transitions, then calls inputRef.current?.focus() at the right moment. However, the useFocusEffect callback below (lines ~170-214) implements a second, parallel focus management system with its own InteractionManager.runAfterInteractions call (line 204), setTimeout retry logic, DeviceCapabilities branching, and immediate focusLastSelected() calls -- all targeting the same input.

Two independent systems racing to focus the same MagicCodeInput makes behavior hard to predict and debug.

Consolidate into a single approach:

  • If useAutoFocusInput is the right tool, remove the manual focus logic from useFocusEffect and let the hook handle everything.
  • If the hook does not meet the requirements (e.g., it calls focus() but you need focusLastSelected()), either extend the hook or remove it and keep only the manual approach.

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

canBeMissing: true,
});

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [canShowError, setCanShowError] = useState<boolean>(false);
const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE);
const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE, {canBeMissing: true});
const validateCodeSent = useMemo(() => hasMagicCodeBeenSent ?? validateCodeAction?.validateCodeSent, [hasMagicCodeBeenSent, validateCodeAction?.validateCodeSent]);
const latestValidateCodeError = getLatestErrorField(validateCodeAction, validateCodeActionErrorField);
const defaultValidateCodeError = getLatestErrorField(validateCodeAction, 'actionVerified');
Expand Down Expand Up @@ -163,29 +169,48 @@ function BaseValidateCodeForm({

useFocusEffect(
useCallback(() => {
if (!inputValidateCodeRef.current) {
return;
}
const focusInput = () => {
inputValidateCodeRef.current?.focusLastSelected();
};

// Force a fresh input instance on each screen focus to avoid stale native focus state on reopen.
setFocusCycle((prev) => prev + 1);

const runFocusWithRetry = () => {
focusInput();
focusTimeoutRef.current = setTimeout(() => {
if (inputRef.current?.isFocused?.()) {
return;
}
focusInput();
}, CONST.ANIMATED_TRANSITION);
};
Comment on lines +177 to +187
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since runFocusWithRetry is called in two places, focusInput ends up being triggered four times. This seems like a hardcoded workaround. @twinkle-react what happens on the latest main with the initial solution using the useAutoFocusInput hook?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!

You're right — the current implementation introduces redundant focus logic.

I’m refactoring this to rely on a single focus mechanism using useAutoFocusInput and limiting the fix to Android only.

Also removing the remount-based approach and simplifying retries.


Comment on lines +172 to +188
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@twinkle-react Which use cases do we need to handle with this change?

Copy link
Copy Markdown
Author

@twinkle-react twinkle-react Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@suneox I fixed the Magic Code keyboard issue by improving how input focus is handled when the screen gains focus. The input is re-initialized and programmatically focused, with a retry after screen transitions to handle Android focus timing issues. This ensures the keyboard opens consistently on both initial and subsequent visits, while behavior on other platforms remains unchanged.

if (focusTimeoutRef.current) {
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 {
inputValidateCodeRef.current?.focusLastSelected();
if (DeviceCapabilities.canUseTouchScreen()) {
focusTimeoutRef.current = setTimeout(runFocusWithRetry, 0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This issue only occurs on Android, and runFocusWithRetry also runs below we have 2 places try to run focus


Comment on lines +193 to +195
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore immediate focus on Mobile Safari

This change removes the old synchronous Mobile Safari focus path and now always defers focus on touch devices via setTimeout(..., 0), but this form previously documented that delayed focus prevents the keyboard from opening on iOS Safari. In mobile web Safari, that means the magic code screen can regress to not opening the keyboard automatically again; keep the Android retry behavior, but preserve immediate focus when isMobileSafari() is true.

Useful? React with 👍 / 👎.

return () => {
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}

const interactionTask = InteractionManager.runAfterInteractions(runFocusWithRetry);

return () => {
interactionTask.cancel();
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}, []),
}, [inputRef]),
);

useEffect(() => {
Expand All @@ -196,6 +221,7 @@ function BaseValidateCodeForm({
countdownRef.current?.resetCountdown();
}, [isCountdownRunning]);

// ✅ Accessibility announce (added from Code 2)
useEffect(() => {
if (!validateCodeSent) {
return;
Expand All @@ -207,9 +233,9 @@ function BaseValidateCodeForm({
if (!validateCodeSent) {
return;
}
// Delay prevents the input from gaining focus before the RHP slide-out animation finishes,
// which would cause issues with the RHP sliding out smoothly and flickering of the wide RHP in the background.
if ((wideRHPRouteKeys.length > 0 && !isMobileSafari()) || isInPageModal) {
const shouldDelayClear = !DeviceCapabilities.canUseTouchScreen() && ((wideRHPRouteKeys.length > 0 && !isMobileSafari()) || isInPageModal);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the clear delay for touch page-modals

When isInPageModal is true (it defaults to true in ValidateCodeActionContent), this condition is now false on every native/touch device because canUseTouchScreen() returns true there, so we call MagicCodeInput.clear() immediately instead of after the transition. clear() focuses the hidden text input again, which is the exact focus-during-transition path the old code and ContactMethodDetailsPage were guarding against; on mobile validate-code page modals this can reintroduce the slide/flicker/keyboard pop while the modal is opening or after resend.

Useful? React with 👍 / 👎.


if (shouldDelayClear) {
focusTimeoutRef.current = setTimeout(() => {
inputValidateCodeRef.current?.clear();
}, CONST.ANIMATED_TRANSITION);
Expand All @@ -218,47 +244,32 @@ function BaseValidateCodeForm({
}
}, [validateCodeSent, wideRHPRouteKeys.length, isInPageModal]);

/**
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
sendValidateCode();
inputValidateCodeRef.current?.clear();
countdownRef.current?.resetCountdown();
setIsCountdownRunning(true);
};

/**
* Handle text input and clear formError upon text change
*/
const onTextInput = useCallback(
(text: string) => {
setValidateCode(text);
setFormError({});

if (!isEmptyObject(validateError) || !isEmptyObject(latestValidateCodeError)) {
// Clear flow specific error
clearError();

// Clear "incorrect magic code" error
clearValidateCodeActionError(validateCodeActionErrorField);
}
},
[validateError, clearError, latestValidateCodeError, validateCodeActionErrorField],
);

/**
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
// Clear flow specific error
clearError();

// Clear "incorrect magic" code error
clearValidateCodeActionError(validateCodeActionErrorField);

clearDefaultValidationCodeError();
setCanShowError(true);

if (!validateCode.trim()) {
setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'});
return;
Expand Down Expand Up @@ -289,12 +300,12 @@ function BaseValidateCodeForm({
setIsCountdownRunning(false);
}, []);

// latestValidateCodeError only holds an error related to bad magic code
// while validateError holds flow-specific errors
const finalValidateError = !isEmptyObject(latestValidateCodeError) ? latestValidateCodeError : validateError;

return (
<>
<MagicCodeInput
key={focusCycle}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid remounting MagicCodeInput on each focus cycle

Setting key={focusCycle} recreates MagicCodeInput every time this screen gains focus, which resets its internal wasSubmitted guard used to prevent repeated onFulfill calls after a full code is entered. If a user returns to the focused screen with an already-complete code value, the remounted input can auto-submit that same code again and trigger duplicate verify requests.

Useful? React with 👍 / 👎.

autoComplete={autoComplete}
ref={inputValidateCodeRef}
name="validateCode"
Expand All @@ -304,6 +315,7 @@ function BaseValidateCodeForm({
hasError={canShowError && !isEmptyObject(finalValidateError)}
onFulfill={validateAndSubmitForm}
autoFocus={false}
inputCallbackRef={inputCallbackRef}
/>
{shouldShowTimer && (
<View style={[styles.mt5, styles.flexRow, styles.renderHTML]}>
Expand All @@ -313,6 +325,7 @@ function BaseValidateCodeForm({
/>
</View>
)}

<OfflineWithFeedback
pendingAction={validateCodeAction?.pendingFields?.validateCodeSent}
errorRowStyles={[styles.mt2]}
Expand All @@ -336,12 +349,13 @@ function BaseValidateCodeForm({
</View>
)}
</OfflineWithFeedback>

{/* ✅ accessibility wrapper added (from Code 2) */}
<View accessibilityLiveRegion="polite">
{!!validateCodeSent && (
<DotIndicatorMessage
type="success"
style={[styles.mt6, styles.flex0]}
// eslint-disable-next-line @typescript-eslint/naming-convention
messages={{0: translate('validateCodeModal.successfulNewCodeRequest')}}
/>
)}
Expand All @@ -368,7 +382,6 @@ function BaseValidateCodeForm({
style={[styles.mt4]}
success={false}
large
sentryLabel={CONST.SENTRY_LABEL.VALIDATE_CODE.SKIP}
/>
)}
{!hideSubmitButton && (
Expand All @@ -379,9 +392,7 @@ function BaseValidateCodeForm({
style={[shouldShowSkipButton ? styles.mt3 : styles.mt4]}
success
large
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
isLoading={account?.isLoading || isLoading}
sentryLabel={CONST.SENTRY_LABEL.VALIDATE_CODE.VERIFY}
/>
)}
</OfflineWithFeedback>
Expand All @@ -391,4 +402,4 @@ function BaseValidateCodeForm({

export type {ValidateCodeFormProps, ValidateCodeFormHandle};

export default BaseValidateCodeForm;
export default BaseValidateCodeForm;
Loading