diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 7b1f67fa1d17..d2282103643b 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -135,9 +135,17 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } - // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. Only redirect - // validated accounts; unvalidated users mid-AddWorkEmail can legitimately land here while isFromPublicDomain is stale. - if (isUserFromPublicDomain && isAccountValidated && initialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route)) { + // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for an unvalidated user still confirming their + // work email; unvalidated users mid-AddWorkEmail can legitimately land here while isFromPublicDomain is stale. Once the + // account is validated, BaseOnboardingPrivateDomain redirects forward off this screen, so the router must agree and also + // resolve forward — otherwise it keeps resetting back onto private-domain and the two ping-pong, spamming + // history.replaceState until Safari throws a SecurityError (its 100-calls-per-10s rate limit). See Expensify/App#97473. + if (isAccountValidated && initialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route)) { + // A validated private-domain user with accessible policies is sent to the Workspaces ("join a workspace") screen by + // the page effect — mirror that so the router doesn't send them back to private-domain. + if (!isUserFromPublicDomain && hasAccessiblePolicies) { + return `/${ROUTES.ONBOARDING_WORKSPACES.route}`; + } if (isVsbOrSmb) { return `/${ROUTES.ONBOARDING_EMPLOYEES.route}`; } diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index bead0176e385..4ca352b4adb2 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -26,7 +26,7 @@ import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingPrivateDomainProps} from './types'; @@ -104,8 +104,18 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard sendValidateCode(); }, [sendValidateCode, isValidated, shouldBlockPublicDomain]); + // Fire the forced-replace redirect at most once per mount. Without this guard, a dependency flip (e.g. the + // accessible-policies loading flag toggling) can re-run the effect and re-dispatch the redirect, which drives + // history.replaceState in a loop and trips Safari's rate limit. See Expensify/App#97473. + const hasRedirectedRef = useRef(false); + useEffect(() => { + if (hasRedirectedRef.current) { + return; + } + if (shouldBlockPublicDomain) { + hasRedirectedRef.current = true; navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); return; } @@ -115,6 +125,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard } if (joinablePoliciesLength > 0) { + hasRedirectedRef.current = true; Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); return; } @@ -122,6 +133,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard // When validation succeeded but there are no joinable workspaces and the API call has completed, // navigate to the next onboarding step (same as the skip button behavior). if (getAccessiblePoliciesAction?.loading === false) { + hasRedirectedRef.current = true; navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); } }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, shouldBlockPublicDomain, navigateToNextOnboardingStep]); diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 3d3db8e7e663..6c7c9a7d5fcd 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -173,6 +173,98 @@ describe('OnboardingFlow', () => { expect(path).toBe('/onboarding/purpose'); }); + it('should resolve a validated private-domain user with accessible policies forward to the workspaces step', () => { + // Regression test for Expensify/App#97473: the router must not return the stale private-domain path for a + // validated private-domain user, otherwise it ping-pongs with the screen's own forward redirect and spams + // history.replaceState past Safari's rate limit. + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: false, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/join-workspaces'); + }); + + it('should resolve a validated private-domain user without accessible policies forward to the purpose step', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: false, + hasAccessiblePolicies: false, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/purpose'); + }); + + it('should resolve a validated private-domain SMB user forward to the employees step', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: false, + hasAccessiblePolicies: false, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.EMPLOYER, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/employees'); + }); + + it('should not redirect a private-domain unvalidated user with accessible policies away from private-domain', () => { + // An unvalidated private-domain user is still confirming their code on the screen; the router must keep them there. + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: false, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: false, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/private-domain'); + }); + it('should not redirect away from a private-domain URL for a public-domain unvalidated user', () => { // Mirrors the BaseOnboardingPrivateDomain screen-level guard: an unvalidated public-domain user who just // submitted a work email may land here while isFromPublicDomain is stale. They must keep the private-domain step.