Skip to content

Commit 4fb1a77

Browse files
committed
chore(runway): cherry-pick fix(card): cp-7.60.0 debounced inputs on onboarding flow (#22747)
<!-- 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? --> This PR fixes a race condition bug in the onboarding flow where users typing quickly and immediately pressing submit buttons would have their input data lost due to debounce delays. ## **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 a bug where rapidly typing in Card onboarding forms and immediately submitting could send incomplete data to the server ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Onboarding form submission with rapid typing Scenario: user types email rapidly and submits immediately in Sign Up screen Given user is on the Sign Up screen And all form fields are empty When user rapidly types "test@example.com" in email field And user rapidly types "Password123!" in password field And user rapidly types "Password123!" in confirm password field And user selects a country And user immediately presses Continue button (within 1 second) Then the API should receive "test@example.com" (not partial/stale email) And user should navigate to Confirm Email screen Scenario: user types phone number rapidly and submits immediately in Set Phone Number screen Given user is on the Set Phone Number screen And country code is selected When user rapidly types "1234567890" in phone number field And user immediately presses Continue button (within 1 second) Then the API should receive "1234567890" (not partial phone number) And user should navigate to Confirm Phone Number screen Scenario: US user types SSN rapidly and submits immediately in Personal Details screen Given user is on the Personal Details screen And user has selected US as country And first name, last name, date of birth, and nationality are filled When user rapidly types "123456789" (9 digits) in SSN field And user immediately presses Continue button (within 1 second) Then the API should receive "123456789" (complete SSN, not partial) And user should navigate to Physical Address screen Scenario: user submits invalid data with rapid typing Given user is on the Sign Up screen When user rapidly types "invalid-email" in email field And user rapidly types "weak" in password field And user rapidly types "weak" in confirm password field And user selects a country And user immediately presses Continue button Then validation errors should be shown And API should not be called And user should remain on Sign Up screen ``` ## **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] > Use non-debounced field values for validation/submission in onboarding forms and switch consent `policyType` from `US` to `us`. > > - **Onboarding UI (validation/submission)**: > - Sign Up (`SignUp.tsx`): validate/submit using live `email`, `password`, `confirmPassword`; update `isDisabled`; send current values to API; tweak handlers. > - Set Phone Number (`SetPhoneNumber.tsx`): validate `phoneNumber` (4–15 digits) before submit; compute `isDisabled` from current value; pass live number to API/navigation; adjust error handling. > - Personal Details (`PersonalDetails.tsx`): require and validate `SSN` (9 digits) for US before submit; compute `isDisabled` using live `SSN`; send current `ssn` to API. > - **Consent policy casing**: > - Change consent `policyType` from `US` to `us` across code, tests, and types (`useRegisterUserConsent.ts`, `useRegisterUserConsent.test.ts`, `CardSDK.test.ts`, `types.ts`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b039631. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a5246fe commit 4fb1a77

File tree

7 files changed

+125
-98
lines changed

7 files changed

+125
-98
lines changed

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

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,20 @@ const PersonalDetails = () => {
172172
!lastName ||
173173
!dateOfBirth ||
174174
!nationality ||
175-
(!debouncedSSN && selectedCountry === 'US')
175+
(!SSN && selectedCountry === 'US')
176176
) {
177177
return;
178178
}
179179

180+
// Validate SSN before submitting if it's a US user
181+
if (selectedCountry === 'US') {
182+
const isSSNValid = /^\d{9}$/.test(SSN);
183+
if (!isSSNValid) {
184+
setIsSSNError(true);
185+
return;
186+
}
187+
}
188+
180189
try {
181190
trackEvent(
182191
createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
@@ -192,7 +201,7 @@ const PersonalDetails = () => {
192201
lastName,
193202
dateOfBirth: formatDateOfBirth(dateOfBirth),
194203
countryOfNationality: nationality,
195-
ssn: debouncedSSN,
204+
ssn: SSN,
196205
});
197206

198207
if (user) {
@@ -223,32 +232,35 @@ const PersonalDetails = () => {
223232
);
224233
}, [trackEvent, createEventBuilder]);
225234

226-
const isDisabled = useMemo(
227-
() =>
235+
const isDisabled = useMemo(() => {
236+
// Check the actual SSN value, not the debounced one
237+
const isSSNValid =
238+
SSN && selectedCountry === 'US' ? /^\d{9}$/.test(SSN) : true;
239+
240+
return (
228241
registerLoading ||
229242
registerIsError ||
230243
!firstName ||
231244
!lastName ||
232245
!dateOfBirth ||
233246
!nationality ||
234-
(!debouncedSSN && selectedCountry === 'US') ||
235-
isSSNError ||
247+
(!SSN && selectedCountry === 'US') ||
248+
!isSSNValid ||
236249
!!dateError ||
237-
!onboardingId,
238-
[
239-
registerLoading,
240-
registerIsError,
241-
firstName,
242-
lastName,
243-
dateOfBirth,
244-
nationality,
245-
debouncedSSN,
246-
selectedCountry,
247-
isSSNError,
248-
dateError,
249-
onboardingId,
250-
],
251-
);
250+
!onboardingId
251+
);
252+
}, [
253+
registerLoading,
254+
registerIsError,
255+
firstName,
256+
lastName,
257+
dateOfBirth,
258+
nationality,
259+
SSN,
260+
selectedCountry,
261+
dateError,
262+
onboardingId,
263+
]);
252264

253265
const renderFormFields = () => (
254266
<>

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

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,13 @@ const SetPhoneNumber = () => {
8484
}, [trackEvent, createEventBuilder]);
8585

8686
const handleContinue = async () => {
87-
if (
88-
!debouncedPhoneNumber ||
89-
!selectedCountryAreaCode ||
90-
!contactVerificationId
91-
) {
87+
if (!phoneNumber || !selectedCountryAreaCode || !contactVerificationId) {
88+
return;
89+
}
90+
91+
const isCurrentPhoneNumberValid = /^\d{4,15}$/.test(phoneNumber);
92+
if (!isCurrentPhoneNumberValid) {
93+
setIsPhoneNumberError(true);
9294
return;
9395
}
9496

@@ -103,21 +105,21 @@ const SetPhoneNumber = () => {
103105
);
104106
const { success } = await sendPhoneVerification({
105107
phoneCountryCode: selectedCountryAreaCode,
106-
phoneNumber: debouncedPhoneNumber,
108+
phoneNumber,
107109
contactVerificationId,
108110
});
111+
109112
if (success) {
110113
navigation.navigate(Routes.CARD.ONBOARDING.CONFIRM_PHONE_NUMBER, {
111114
phoneCountryCode: selectedCountryAreaCode,
112-
phoneNumber: debouncedPhoneNumber,
115+
phoneNumber,
113116
});
114117
}
115-
} catch (error) {
118+
} catch (err: unknown) {
116119
if (
117-
error instanceof CardError &&
118-
error.message.includes('Invalid or expired contact verification ID')
120+
err instanceof CardError &&
121+
err.message.includes('Invalid or expired contact verification ID')
119122
) {
120-
// navigate back and restart the flow
121123
dispatch(resetOnboardingState());
122124
navigation.navigate(Routes.CARD.ONBOARDING.SIGN_UP);
123125
}
@@ -141,29 +143,29 @@ const SetPhoneNumber = () => {
141143
return;
142144
}
143145

144-
setIsPhoneNumberError(
145-
// 4-15 digits
146-
!/^\d{4,15}$/.test(debouncedPhoneNumber),
147-
);
146+
setIsPhoneNumberError(!/^\d{4,15}$/.test(debouncedPhoneNumber));
148147
}, [debouncedPhoneNumber]);
149148

150-
const isDisabled = useMemo(
151-
() =>
152-
!debouncedPhoneNumber ||
149+
const isDisabled = useMemo(() => {
150+
const isCurrentPhoneNumberValid = phoneNumber
151+
? /^\d{4,15}$/.test(phoneNumber)
152+
: false;
153+
154+
return (
155+
!phoneNumber ||
153156
!selectedCountryAreaCode ||
154157
!contactVerificationId ||
155-
isPhoneNumberError ||
158+
!isCurrentPhoneNumberValid ||
156159
phoneVerificationIsLoading ||
157-
phoneVerificationIsError,
158-
[
159-
debouncedPhoneNumber,
160-
selectedCountryAreaCode,
161-
contactVerificationId,
162-
isPhoneNumberError,
163-
phoneVerificationIsLoading,
164-
phoneVerificationIsError,
165-
],
166-
);
160+
phoneVerificationIsError
161+
);
162+
}, [
163+
phoneNumber,
164+
selectedCountryAreaCode,
165+
contactVerificationId,
166+
phoneVerificationIsLoading,
167+
phoneVerificationIsError,
168+
]);
167169

168170
const renderFormFields = () => (
169171
<Box>
@@ -204,7 +206,7 @@ const SetPhoneNumber = () => {
204206
/>
205207
</Box>
206208
</Box>
207-
{debouncedPhoneNumber && phoneVerificationIsError ? (
209+
{phoneNumber && phoneVerificationIsError ? (
208210
<Text
209211
variant={TextVariant.BodySm}
210212
testID="set-phone-number-phone-number-error"

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

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -106,55 +106,69 @@ const SignUp = () => {
106106
setIsConfirmPasswordError(debouncedConfirmPassword !== debouncedPassword);
107107
}, [debouncedConfirmPassword, debouncedPassword]);
108108

109-
const isDisabled = useMemo(
110-
() =>
111-
!debouncedEmail ||
112-
!debouncedPassword ||
113-
!debouncedConfirmPassword ||
109+
const isDisabled = useMemo(() => {
110+
// Check the actual values, not the debounced ones
111+
const isEmailValid = email ? validateEmail(email) : false;
112+
const isPasswordValid = password ? validatePassword(password) : false;
113+
const isConfirmPasswordValid = confirmPassword
114+
? confirmPassword === password
115+
: false;
116+
117+
return (
118+
!email ||
119+
!password ||
120+
!confirmPassword ||
114121
!selectedCountry ||
115-
isPasswordError ||
116-
isConfirmPasswordError ||
117-
isEmailError ||
122+
!isEmailValid ||
123+
!isPasswordValid ||
124+
!isConfirmPasswordValid ||
118125
emailVerificationIsError ||
119-
emailVerificationIsLoading,
120-
[
121-
debouncedEmail,
122-
debouncedPassword,
123-
debouncedConfirmPassword,
124-
selectedCountry,
125-
isPasswordError,
126-
isConfirmPasswordError,
127-
isEmailError,
128-
emailVerificationIsError,
129-
emailVerificationIsLoading,
130-
],
131-
);
126+
emailVerificationIsLoading
127+
);
128+
}, [
129+
email,
130+
password,
131+
confirmPassword,
132+
selectedCountry,
133+
emailVerificationIsError,
134+
emailVerificationIsLoading,
135+
]);
132136

133137
const handleEmailChange = useCallback(
134-
(email: string) => {
138+
(emailText: string) => {
135139
resetEmailVerificationSend();
136-
setEmail(email);
140+
setEmail(emailText);
137141
},
138142
[resetEmailVerificationSend],
139143
);
140144

141145
const handlePasswordChange = useCallback(
142-
(password: string) => {
146+
(passwordText: string) => {
143147
resetEmailVerificationSend();
144-
setPassword(password);
148+
setPassword(passwordText);
145149
},
146150
[resetEmailVerificationSend],
147151
);
148152

149153
const handleContinue = useCallback(async () => {
150-
if (
151-
!debouncedEmail ||
152-
!debouncedPassword ||
153-
!debouncedConfirmPassword ||
154-
!selectedCountry
155-
) {
154+
// Use actual values, not debounced ones
155+
if (!email || !password || !confirmPassword || !selectedCountry) {
156156
return;
157157
}
158+
159+
// Validate current values before submitting
160+
const isEmailValid = validateEmail(email);
161+
const isPasswordValid = validatePassword(password);
162+
const isConfirmPasswordValid = confirmPassword === password;
163+
164+
if (!isEmailValid || !isPasswordValid || !isConfirmPasswordValid) {
165+
// Set error states
166+
setIsEmailError(!isEmailValid);
167+
setIsPasswordError(!isPasswordValid);
168+
setIsConfirmPasswordError(!isConfirmPasswordValid);
169+
return;
170+
}
171+
158172
try {
159173
trackEvent(
160174
createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
@@ -163,15 +177,14 @@ const SignUp = () => {
163177
})
164178
.build(),
165179
);
166-
const { contactVerificationId } =
167-
await sendEmailVerification(debouncedEmail);
180+
const { contactVerificationId } = await sendEmailVerification(email);
168181

169182
dispatch(setContactVerificationId(contactVerificationId));
170183

171184
if (contactVerificationId) {
172185
navigation.navigate(Routes.CARD.ONBOARDING.CONFIRM_EMAIL, {
173-
email: debouncedEmail,
174-
password: debouncedConfirmPassword,
186+
email,
187+
password: confirmPassword,
175188
});
176189
} else {
177190
// If no contactVerificationId, assume user is registered or email not valid
@@ -181,9 +194,9 @@ const SignUp = () => {
181194
// Allow error message to display
182195
}
183196
}, [
184-
debouncedConfirmPassword,
185-
debouncedEmail,
186-
debouncedPassword,
197+
confirmPassword,
198+
email,
199+
password,
187200
dispatch,
188201
navigation,
189202
selectedCountry,
@@ -224,7 +237,7 @@ const SignUp = () => {
224237
isError={debouncedEmail.length > 0 && isEmailError}
225238
testID="signup-email-input"
226239
/>
227-
{debouncedEmail.length > 0 && emailVerificationIsError ? (
240+
{email.length > 0 && emailVerificationIsError ? (
228241
<Text
229242
testID="signup-email-error-text"
230243
variant={TextVariant.BodySm}

app/components/UI/Card/hooks/useRegisterUserConsent.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ describe('useRegisterUserConsent', () => {
187187
});
188188

189189
expect(mockCreateOnboardingConsent).toHaveBeenCalledWith({
190-
policyType: 'US',
190+
policyType: 'us',
191191
onboardingId: testOnboardingId,
192192
consents: [
193193
{
@@ -729,7 +729,7 @@ describe('useRegisterUserConsent', () => {
729729
const countryTestCases = [
730730
{
731731
country: 'US',
732-
expectedPolicy: 'US',
732+
expectedPolicy: 'us',
733733
description: 'US users',
734734
},
735735
{

app/components/UI/Card/hooks/useRegisterUserConsent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => {
9696
throw new Error('Card SDK not initialized');
9797
}
9898

99-
const policy = selectedCountry === 'US' ? 'US' : 'global';
99+
const policy = selectedCountry === 'US' ? 'us' : 'global';
100100

101101
try {
102102
// Reset state and start loading
@@ -117,7 +117,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => {
117117
},
118118
};
119119
const consents: Consent[] = [
120-
...(policy === 'US' ? [eSignActConsent] : []),
120+
...(policy === 'us' ? [eSignActConsent] : []),
121121
{
122122
consentType: 'termsAndPrivacy',
123123
consentStatus: 'granted',

app/components/UI/Card/sdk/CardSDK.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,7 +2712,7 @@ describe('CardSDK', () => {
27122712
describe('createOnboardingConsent', () => {
27132713
it('creates onboarding consent successfully', async () => {
27142714
const mockRequest: Omit<CreateOnboardingConsentRequest, 'tenantId'> = {
2715-
policyType: 'US',
2715+
policyType: 'us',
27162716
onboardingId: 'onboarding123',
27172717
consents: [],
27182718
metadata: {
@@ -2749,7 +2749,7 @@ describe('CardSDK', () => {
27492749

27502750
it('handles create onboarding consent error', async () => {
27512751
const mockRequest: Omit<CreateOnboardingConsentRequest, 'tenantId'> = {
2752-
policyType: 'US',
2752+
policyType: 'us',
27532753
onboardingId: 'onboarding123',
27542754
consents: [],
27552755
metadata: {
@@ -2789,7 +2789,7 @@ describe('CardSDK', () => {
27892789
accepted: true,
27902790
},
27912791
],
2792-
policyType: 'US',
2792+
policyType: 'us',
27932793
};
27942794

27952795
(global.fetch as jest.Mock).mockResolvedValue({

0 commit comments

Comments
 (0)