Skip to content

Commit 656cfdf

Browse files
committed
Fix infinite onboarding navigation loop crash (APP-7FR)
The OnboardingGuard would unconditionally REDIRECT to the onboarding route whenever shouldSkipOnboarding was false, even when the user was already on the OnboardingModalNavigator. Each REDIRECT produced a CommonActions.reset that changed the navigation state, which triggered downstream effects dispatching further actions, re-entering the guard, and looping until React hit maximum update depth. Add a check for OnboardingModalNavigator already being present in the navigation state routes before issuing a REDIRECT. This makes the guard idempotent: once the user is on onboarding, subsequent evaluations return ALLOW instead of producing redundant resets. Made-with: Cursor
1 parent 9bcf248 commit 656cfdf

2 files changed

Lines changed: 98 additions & 52 deletions

File tree

src/libs/Navigation/guards/OnboardingGuard.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ const OnboardingGuard: NavigationGuard = {
198198
return {type: 'ALLOW'};
199199
}
200200

201+
// If the OnboardingModalNavigator is already in the navigation state, the user is already
202+
// on the onboarding flow. Redirecting again would produce a redundant state reset that
203+
// triggers further actions, creating an infinite navigation loop (APP-7FR).
204+
const isAlreadyOnOnboarding = state.routes.some((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR);
205+
if (isAlreadyOnOnboarding) {
206+
return {type: 'ALLOW'};
207+
}
208+
201209
// User needs onboarding - calculate the correct step and redirect
202210
const onboardingRoute = getOnboardingRoute();
203211

tests/unit/Navigation/guards/OnboardingGuard.test.ts

Lines changed: 90 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ describe('OnboardingGuard', () => {
326326
});
327327

328328
describe('redirect to onboarding', () => {
329-
it('should redirect when authenticated user needs onboarding', async () => {
329+
it('should redirect when authenticated user needs onboarding and is not on onboarding', async () => {
330330
// Given a new user from a public email domain who has not completed the guided setup flow
331331
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
332332
hasCompletedGuidedSetupFlow: false,
@@ -336,7 +336,7 @@ describe('OnboardingGuard', () => {
336336
});
337337
await waitForBatchedUpdates();
338338

339-
// When the guard evaluates a navigation action to a non-onboarding screen
339+
// When the guard evaluates a navigation action while the user is on a non-onboarding screen
340340
const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
341341

342342
// Then the user should be redirected to onboarding because new users must complete the setup flow before accessing the app
@@ -355,25 +355,43 @@ describe('OnboardingGuard', () => {
355355
});
356356
await waitForBatchedUpdates();
357357

358-
// When the guard evaluates a navigation action
358+
// When the guard evaluates a navigation action while the user is on a non-onboarding screen
359359
const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
360360

361361
// Then the user should be redirected to onboarding because their domain/policy context determines which onboarding step they should land on
362362
expect(result.type).toBe('REDIRECT');
363363
expect(result.route).toContain('onboarding');
364364
});
365+
});
365366

366-
it('should redirect when user tries to access wrong onboarding step', async () => {
367-
// Given a new user from a public domain who is currently on the onboarding purpose screen but may need to be on a different step
368-
const onboardingState: NavigationState = {
369-
key: 'root',
370-
index: 0,
371-
routeNames: [SCREENS.ONBOARDING.PURPOSE],
372-
routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}],
373-
stale: false,
374-
type: 'root',
375-
};
367+
describe('infinite loop prevention (APP-7FR)', () => {
368+
// A realistic navigation state that matches what the guard's REDIRECT reset produces:
369+
// HOME at the bottom, OnboardingModalNavigator on top (focused).
370+
const stateWithOnboardingNavigator: NavigationState = {
371+
key: 'root',
372+
index: 1,
373+
routeNames: [SCREENS.HOME, NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR],
374+
routes: [
375+
{key: 'home', name: SCREENS.HOME},
376+
{
377+
key: 'onboarding-modal',
378+
name: NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR,
379+
state: {
380+
key: 'onboarding-stack',
381+
index: 0,
382+
routeNames: [SCREENS.ONBOARDING.WORK_EMAIL],
383+
routes: [{key: 'work-email', name: SCREENS.ONBOARDING.WORK_EMAIL}],
384+
stale: false,
385+
type: 'stack',
386+
},
387+
},
388+
],
389+
stale: false,
390+
type: 'stack',
391+
};
376392

393+
it('should ALLOW when user is already on onboarding to prevent redirect loop', async () => {
394+
// Given a HybridApp user who needs onboarding (all shouldSkipOnboarding conditions are false)
377395
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
378396
hasCompletedGuidedSetupFlow: false,
379397
});
@@ -382,31 +400,39 @@ describe('OnboardingGuard', () => {
382400
});
383401
await waitForBatchedUpdates();
384402

385-
// When the guard evaluates a navigation action while the user is on a potentially incorrect onboarding step
386-
const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
403+
// When the guard evaluates any action while the user is already on the OnboardingModalNavigator
404+
const result = OnboardingGuard.evaluate(stateWithOnboardingNavigator, mockAction, authenticatedContext);
387405

388-
// Then the user should be redirected to the correct onboarding step because the guard enforces the proper step sequence
389-
expect(result.type).toBe('REDIRECT');
390-
expect(result.route).toContain('onboarding');
406+
// Then navigation should be ALLOWED because the user is already on onboarding;
407+
// redirecting again would produce a redundant state reset that causes an infinite loop
408+
expect(result.type).toBe('ALLOW');
391409
});
392410

393-
it('should redirect when user in onboarding tries to access non-onboarding path', async () => {
394-
// Given a new user from a public domain who is currently on the onboarding purpose screen
395-
const onboardingState: NavigationState = {
396-
key: 'root',
397-
index: 0,
398-
routeNames: [SCREENS.ONBOARDING.PURPOSE],
399-
routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}],
400-
stale: false,
401-
type: 'root',
402-
};
411+
it('should prove the guard reaches a stable state (no infinite loop)', async () => {
412+
// Given a user who needs onboarding
413+
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
414+
hasCompletedGuidedSetupFlow: false,
415+
});
416+
await Onyx.merge(ONYXKEYS.ACCOUNT, {
417+
isFromPublicDomain: true,
418+
});
419+
await waitForBatchedUpdates();
403420

404-
// When the user attempts to navigate to the HOME screen before completing onboarding
405-
const homeAction: NavigationAction = {
406-
type: 'NAVIGATE',
407-
payload: {name: SCREENS.HOME},
408-
};
421+
// When the guard first evaluates on a non-onboarding state, it redirects
422+
const firstResult = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext);
423+
expect(firstResult.type).toBe('REDIRECT');
424+
425+
// And then subsequent evaluations on the post-redirect state (OnboardingModalNavigator mounted)
426+
// reach a stable ALLOW state, breaking any potential loop
427+
const secondResult = OnboardingGuard.evaluate(stateWithOnboardingNavigator, mockAction, authenticatedContext);
428+
expect(secondResult.type).toBe('ALLOW');
409429

430+
const thirdResult = OnboardingGuard.evaluate(stateWithOnboardingNavigator, mockAction, authenticatedContext);
431+
expect(thirdResult.type).toBe('ALLOW');
432+
});
433+
434+
it('should still redirect when user is NOT on onboarding and needs it', async () => {
435+
// Given a user who needs onboarding and is on the HOME screen (not on onboarding)
410436
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
411437
hasCompletedGuidedSetupFlow: false,
412438
});
@@ -415,38 +441,50 @@ describe('OnboardingGuard', () => {
415441
});
416442
await waitForBatchedUpdates();
417443

418-
const result = OnboardingGuard.evaluate(onboardingState, homeAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
444+
// When the guard evaluates on a state without OnboardingModalNavigator
445+
const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
419446

420-
// Then the user should be redirected back to onboarding because they must complete the setup flow before accessing other parts of the app
447+
// Then the guard should redirect because the user needs onboarding and isn't on it yet
421448
expect(result.type).toBe('REDIRECT');
422449
expect(result.route).toContain('onboarding');
423450
});
424451

425-
it('should always redirect to correct onboarding step when user needs onboarding', async () => {
426-
// Given a new user from a public domain who is currently on the work-email onboarding step but the guard determines they belong on a different step
427-
const onboardingState: NavigationState = {
452+
it('should still BLOCK RESET to non-onboarding even when on onboarding', async () => {
453+
// Given a user on onboarding who has not completed it
454+
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
455+
hasCompletedGuidedSetupFlow: false,
456+
});
457+
await waitForBatchedUpdates();
458+
459+
// Note: shouldPreventReset uses findFocusedRoute which checks the deepest focused route name.
460+
// In a state with onboarding screens at the root level (as used by shouldPreventReset tests),
461+
// the focused route IS an onboarding screen name.
462+
const onboardingRootState: NavigationState = {
428463
key: 'root',
429464
index: 0,
430-
routeNames: [SCREENS.ONBOARDING.WORK_EMAIL],
431-
routes: [{key: 'work-email', name: SCREENS.ONBOARDING.WORK_EMAIL}],
465+
routeNames: [SCREENS.ONBOARDING.PURPOSE],
466+
routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}],
432467
stale: false,
433468
type: 'root',
434469
};
435470

436-
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {
437-
hasCompletedGuidedSetupFlow: false,
438-
});
439-
await Onyx.merge(ONYXKEYS.ACCOUNT, {
440-
isFromPublicDomain: true,
441-
});
442-
await waitForBatchedUpdates();
471+
const resetToHome: NavigationAction = {
472+
type: CONST.NAVIGATION_ACTIONS.RESET,
473+
payload: {
474+
key: 'root',
475+
index: 0,
476+
routeNames: [SCREENS.HOME],
477+
routes: [{key: 'home', name: SCREENS.HOME}],
478+
stale: false,
479+
type: 'root',
480+
},
481+
};
443482

444-
// When the guard evaluates a navigation action while the user is on a specific onboarding step
445-
const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string};
483+
const result = OnboardingGuard.evaluate(onboardingRootState, resetToHome, authenticatedContext) as {type: 'BLOCK'; reason?: string};
446484

447-
// Then the guard should redirect to the correct onboarding step because the step sequence is dynamically determined by the user's account state
448-
expect(result.type).toBe('REDIRECT');
449-
expect(result.route).toContain('onboarding');
485+
// Then the RESET should still be blocked by shouldPreventReset (runs before the new check)
486+
expect(result.type).toBe('BLOCK');
487+
expect(result.reason).toBe('Cannot reset to non-onboarding screen while on onboarding');
450488
});
451489
});
452490
});

0 commit comments

Comments
 (0)