Skip to content
Draft
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
14 changes: 11 additions & 3 deletions src/libs/actions/Welcome/OnboardingFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -115,13 +125,15 @@ 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;
}

// 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]);
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/OnboardingFlowTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading