Skip to content

Commit a40aa3f

Browse files
chore(runway): cherry-pick fix(card): cp-7.60.0 fix OTP inputs frozen screens (#22902)
- fix(card): cp-7.60.0 fix OTP inputs frozen screens (#22894) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Fixed critical OTP input freezing issues affecting three Card onboarding/authentication screens where users were unable to navigate, proceed, or sometimes even focus the 6-digit OTP input field. **Problem:** Users experienced UI freezing on OTP verification screens, preventing them from: - Focusing the OTP input field - Navigating back to previous screens - Proceeding after entering the verification code **Root Cause:** The implementations were missing the `useBlurOnFulfill` hook from `react-native-confirmation-code-field`, which is critical for proper input lifecycle management. Without this hook: - The input field remained focused after all digits were entered - The keyboard wasn't properly dismissed - Focus conflicts caused the UI to freeze - Navigation became blocked **Solution:** - Added `useBlurOnFulfill` hook to properly manage OTP input focus and blur events - Updated ref handling to use the hook's return value instead of manual useRef - Fixed hook dependency arrays to prevent stale closure issues - Fixed a cooldown timing bug in ConfirmPhoneNumber (was setting cooldown before API success) - Updated all test mocks to include the new hook The implementation now matches the working pattern used in the Ramp/Deposit OTP screen (OtpCode.tsx), which doesn't experience these issues. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed OTP input freezing issues on Card email and phone verification screens ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates useBlurOnFulfill and platform-specific autoComplete for OTP fields, fixes resend cooldown timing, and updates tests/mocks accordingly. > > - **Card OTP flows** (`CardAuthentication`, `ConfirmEmail`, `ConfirmPhoneNumber`): > - Add `useBlurOnFulfill` for proper focus/blur handling and use its ref for `CodeField`. > - Update focus effects and dependency arrays; remove manual `useRef` usage. > - Use platform-specific `autoComplete` (`android: sms-otp`, default: `one-time-code`). > - Keep auto-submit on full code entry; maintain `useClearByFocusCell`. > - **Resend logic**: > - `ConfirmPhoneNumber`: start cooldown only after successful resend. > - `ConfirmEmail`: store returned `contactVerificationId` from resend. > - **Tests**: > - Update mocks for `react-native-confirmation-code-field` to include `useBlurOnFulfill` and align with ref changes. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9b259a1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [9f9609f](9f9609f) Co-authored-by: Bruno Nascimento <brunonascimentodev@gmail.com>
1 parent 356225c commit a40aa3f

File tree

6 files changed

+164
-40
lines changed

6 files changed

+164
-40
lines changed

app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,86 @@ jest.mock('./CardAuthentication.styles', () => ({
105105
}),
106106
}));
107107

108+
// Mock react-native-confirmation-code-field
109+
jest.mock('react-native-confirmation-code-field', () => {
110+
const React = jest.requireActual('react');
111+
const { TextInput, View, Text } = jest.requireActual('react-native');
112+
113+
const MockCodeField = React.forwardRef(
114+
(
115+
{
116+
value,
117+
onChangeText,
118+
cellCount,
119+
keyboardType,
120+
textContentType,
121+
autoComplete,
122+
renderCell,
123+
rootStyle,
124+
...props
125+
}: {
126+
value: string;
127+
onChangeText?: (text: string) => void;
128+
cellCount: number;
129+
keyboardType?: string;
130+
textContentType?: string;
131+
autoComplete?: string;
132+
renderCell?: (params: {
133+
index: number;
134+
symbol: string;
135+
isFocused: boolean;
136+
}) => React.ReactNode;
137+
rootStyle?: unknown;
138+
[key: string]: unknown;
139+
},
140+
ref: React.Ref<typeof TextInput>,
141+
) =>
142+
React.createElement(
143+
View,
144+
{ testID: 'code-field', style: rootStyle },
145+
React.createElement(TextInput, {
146+
ref,
147+
testID: 'otp-code-field',
148+
value,
149+
onChangeText,
150+
keyboardType,
151+
textContentType,
152+
autoComplete,
153+
maxLength: cellCount,
154+
...props,
155+
}),
156+
Array.from({ length: cellCount }, (_, index) => {
157+
const symbol = value[index] || '';
158+
const isFocused = index === value.length;
159+
return renderCell
160+
? renderCell({ index, symbol, isFocused })
161+
: React.createElement(
162+
View,
163+
{ key: index, testID: `code-cell-${index}` },
164+
React.createElement(Text, null, symbol),
165+
);
166+
}),
167+
),
168+
);
169+
170+
const MockCursor = () => React.createElement(Text, { testID: 'cursor' }, '|');
171+
172+
const mockUseBlurOnFulfill = jest.fn(() => {
173+
const ref = React.useRef({
174+
focus: jest.fn(),
175+
blur: jest.fn(),
176+
});
177+
return ref;
178+
});
179+
180+
return {
181+
CodeField: MockCodeField,
182+
Cursor: MockCursor,
183+
useClearByFocusCell: jest.fn(() => [{}, jest.fn()]),
184+
useBlurOnFulfill: mockUseBlurOnFulfill,
185+
};
186+
});
187+
108188
jest.mock('../../../../../../locales/i18n', () => ({
109189
strings: (key: string, params?: Record<string, string | number>) => {
110190
const mockStrings: { [key: string]: string } = {

app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { useNavigation } from '@react-navigation/native';
2-
import React, {
3-
useCallback,
4-
useEffect,
5-
useMemo,
6-
useRef,
7-
useState,
8-
} from 'react';
2+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
93
import {
104
Image,
115
KeyboardAvoidingView,
@@ -15,6 +9,7 @@ import {
159
View,
1610
TextInput,
1711
StyleSheet,
12+
TextInputProps,
1813
} from 'react-native';
1914
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
2015

@@ -46,6 +41,7 @@ import Logger from '../../../../../util/Logger';
4641
import {
4742
CodeField,
4843
Cursor,
44+
useBlurOnFulfill,
4945
useClearByFocusCell,
5046
} from 'react-native-confirmation-code-field';
5147
import { useStyles } from '../../../../../component-library/hooks';
@@ -56,6 +52,10 @@ import { setOnboardingId } from '../../../../../core/redux/slices/card';
5652
import { CardActions, CardScreens } from '../../util/metrics';
5753

5854
const CELL_COUNT = 6;
55+
const autoComplete = Platform.select<TextInputProps['autoComplete']>({
56+
android: 'sms-otp',
57+
default: 'one-time-code',
58+
});
5959

6060
// Styles for the OTP CodeField
6161
const createOtpStyles = (params: { theme: Theme }) => {
@@ -102,7 +102,6 @@ const CardAuthentication = () => {
102102
string | null
103103
>(null);
104104
const [resendCountdown, setResendCountdown] = useState(60);
105-
const otpInputRef = useRef<TextInput>(null);
106105
const dispatch = useDispatch();
107106
const theme = useTheme();
108107
const {
@@ -174,12 +173,18 @@ const CardAuthentication = () => {
174173
}
175174
}, [step, resendCountdown]);
176175

176+
const otpInputRef =
177+
useBlurOnFulfill({
178+
value: confirmCode,
179+
cellCount: CELL_COUNT,
180+
}) || null;
181+
177182
// Focus OTP input when entering OTP step
178183
useEffect(() => {
179184
if (step === 'otp') {
180185
otpInputRef.current?.focus();
181186
}
182-
}, [step]);
187+
}, [step, otpInputRef]);
183188

184189
useEffect(() => {
185190
const screenName =
@@ -360,15 +365,15 @@ const CardAuthentication = () => {
360365
)}
361366
</Label>
362367
<CodeField
363-
ref={otpInputRef}
368+
ref={otpInputRef as React.RefObject<TextInput>}
364369
{...props}
365370
value={confirmCode}
366371
onChangeText={handleOtpValueChange}
367372
cellCount={CELL_COUNT}
368373
rootStyle={otpStyles.codeFieldRoot}
369374
keyboardType="number-pad"
370375
textContentType="oneTimeCode"
371-
autoComplete="one-time-code"
376+
autoComplete={autoComplete}
372377
renderCell={({ index, symbol, isFocused }) => (
373378
<View
374379
onLayout={getCellOnLayoutHandler(index)}

app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,19 @@ jest.mock('react-native-confirmation-code-field', () => {
241241
jest.fn(),
242242
];
243243

244+
const mockUseBlurOnFulfill = jest.fn(() => {
245+
const ref = React.useRef({
246+
focus: jest.fn(),
247+
blur: jest.fn(),
248+
});
249+
return ref;
250+
});
251+
244252
return {
245253
CodeField: MockCodeField,
246254
Cursor: MockCursor,
247255
useClearByFocusCell: mockUseClearByFocusCell,
256+
useBlurOnFulfill: mockUseBlurOnFulfill,
248257
};
249258
});
250259

app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import React, {
2-
useCallback,
3-
useState,
4-
useEffect,
5-
useContext,
6-
useRef,
7-
} from 'react';
1+
import React, { useCallback, useState, useEffect, useContext } from 'react';
82
import { useNavigation } from '@react-navigation/native';
93
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
104
import Button, {
@@ -38,14 +32,19 @@ import { IconName } from '../../../../../component-library/components/Icons/Icon
3832
import { useTheme } from '../../../../../util/theme';
3933
import { useStyles } from '../../../../hooks/useStyles';
4034
import { createOTPStyles } from './ConfirmPhoneNumber';
41-
import { TextInput, View } from 'react-native';
35+
import { Platform, TextInput, TextInputProps, View } from 'react-native';
4236
import {
4337
CodeField,
4438
Cursor,
39+
useBlurOnFulfill,
4540
useClearByFocusCell,
4641
} from 'react-native-confirmation-code-field';
4742

4843
const CELL_COUNT = 6;
44+
const autoComplete = Platform.select<TextInputProps['autoComplete']>({
45+
android: 'sms-otp',
46+
default: 'one-time-code',
47+
});
4948

5049
const ConfirmEmail = () => {
5150
const navigation = useNavigation();
@@ -56,7 +55,6 @@ const ConfirmEmail = () => {
5655
const contactVerificationId = useSelector(selectContactVerificationId);
5756
const { trackEvent, createEventBuilder } = useMetrics();
5857
const { toastRef } = useContext(ToastContext);
59-
const inputRef = useRef<TextInput>(null);
6058
const { styles } = useStyles(createOTPStyles, {});
6159
const [latestValueSubmitted, setLatestValueSubmitted] = useState<
6260
string | null
@@ -114,8 +112,9 @@ const ConfirmEmail = () => {
114112
.build(),
115113
);
116114
try {
117-
const { contactVerificationId } = await sendEmailVerification(email);
118-
dispatch(setContactVerificationId(contactVerificationId));
115+
const { contactVerificationId: newContactVerificationId } =
116+
await sendEmailVerification(email);
117+
dispatch(setContactVerificationId(newContactVerificationId));
119118
setResendCooldown(60); // 1 minute cooldown
120119
} catch {
121120
// Allow error message to display
@@ -214,6 +213,17 @@ const ConfirmEmail = () => {
214213
}
215214
}, [resendCooldown]);
216215

216+
const inputRef =
217+
useBlurOnFulfill({
218+
value: confirmCode,
219+
cellCount: CELL_COUNT,
220+
}) || null;
221+
222+
// Focus management
223+
useEffect(() => {
224+
inputRef.current?.focus();
225+
}, [inputRef]);
226+
217227
// Auto-submit when all digits are entered
218228
useEffect(() => {
219229
if (
@@ -225,11 +235,6 @@ const ConfirmEmail = () => {
225235
}
226236
}, [confirmCode, handleContinue, latestValueSubmitted]);
227237

228-
// Focus management
229-
useEffect(() => {
230-
inputRef.current?.focus();
231-
}, []);
232-
233238
const [props, getCellOnLayoutHandler] = useClearByFocusCell({
234239
value: confirmCode,
235240
setValue: handleConfirmCodeChange,
@@ -251,15 +256,15 @@ const ConfirmEmail = () => {
251256
{strings('card.card_onboarding.confirm_email.confirm_code_label')}
252257
</Label>
253258
<CodeField
254-
ref={inputRef}
259+
ref={inputRef as React.RefObject<TextInput>}
255260
{...props}
256261
value={confirmCode}
257262
onChangeText={handleConfirmCodeChange}
258263
cellCount={CELL_COUNT}
259264
rootStyle={styles.codeFieldRoot}
260265
keyboardType="number-pad"
261266
textContentType="oneTimeCode"
262-
autoComplete="one-time-code"
267+
autoComplete={autoComplete}
263268
renderCell={({ index, symbol, isFocused }) => (
264269
<View
265270
onLayout={getCellOnLayoutHandler(index)}

app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,19 @@ jest.mock('react-native-confirmation-code-field', () => {
229229

230230
const MockCursor = () => React.createElement(Text, { testID: 'cursor' }, '|');
231231

232+
const mockUseBlurOnFulfill = jest.fn(() => {
233+
const ref = React.useRef({
234+
focus: jest.fn(),
235+
blur: jest.fn(),
236+
});
237+
return ref;
238+
});
239+
232240
return {
233241
CodeField: MockCodeField,
234242
Cursor: MockCursor,
235243
useClearByFocusCell: jest.fn(() => [{}, jest.fn()]),
244+
useBlurOnFulfill: mockUseBlurOnFulfill,
236245
};
237246
});
238247

0 commit comments

Comments
 (0)