@@ -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