@@ -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' ) ;
429+
430+ const thirdResult = OnboardingGuard . evaluate ( stateWithOnboardingNavigator , mockAction , authenticatedContext ) ;
431+ expect ( thirdResult . type ) . toBe ( 'ALLOW' ) ;
432+ } ) ;
409433
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,22 +441,27 @@ 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 redirect when onboarding is in routes but not focused' , async ( ) => {
453+ // Given a user who needs onboarding, and a state where OnboardingModalNavigator
454+ // exists in routes but HOME is focused (index: 0)
455+ const stateWithOnboardingUnfocused : NavigationState = {
428456 key : 'root' ,
429457 index : 0 ,
430- routeNames : [ SCREENS . ONBOARDING . WORK_EMAIL ] ,
431- routes : [ { key : 'work-email' , name : SCREENS . ONBOARDING . WORK_EMAIL } ] ,
458+ routeNames : [ SCREENS . HOME , NAVIGATORS . ONBOARDING_MODAL_NAVIGATOR ] ,
459+ routes : [
460+ { key : 'home' , name : SCREENS . HOME } ,
461+ { key : 'onboarding-modal' , name : NAVIGATORS . ONBOARDING_MODAL_NAVIGATOR } ,
462+ ] ,
432463 stale : false ,
433- type : 'root ' ,
464+ type : 'stack ' ,
434465 } ;
435466
436467 await Onyx . merge ( ONYXKEYS . NVP_ONBOARDING , {
@@ -441,12 +472,50 @@ describe('OnboardingGuard', () => {
441472 } ) ;
442473 await waitForBatchedUpdates ( ) ;
443474
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 } ;
475+ // When the guard evaluates while onboarding is NOT focused
476+ const result = OnboardingGuard . evaluate ( stateWithOnboardingUnfocused , mockAction , authenticatedContext ) as { type : 'REDIRECT' ; route : string } ;
446477
447- // Then the guard should redirect to the correct onboarding step because the step sequence is dynamically determined by the user's account state
478+ // Then the guard should still redirect because the user isn't actively on onboarding
448479 expect ( result . type ) . toBe ( 'REDIRECT' ) ;
449480 expect ( result . route ) . toContain ( 'onboarding' ) ;
450481 } ) ;
482+
483+ it ( 'should still BLOCK RESET to non-onboarding even when on onboarding' , async ( ) => {
484+ // Given a user on onboarding who has not completed it
485+ await Onyx . merge ( ONYXKEYS . NVP_ONBOARDING , {
486+ hasCompletedGuidedSetupFlow : false ,
487+ } ) ;
488+ await waitForBatchedUpdates ( ) ;
489+
490+ // Note: shouldPreventReset uses findFocusedRoute which checks the deepest focused route name.
491+ // In a state with onboarding screens at the root level (as used by shouldPreventReset tests),
492+ // the focused route IS an onboarding screen name.
493+ const onboardingRootState : NavigationState = {
494+ key : 'root' ,
495+ index : 0 ,
496+ routeNames : [ SCREENS . ONBOARDING . PURPOSE ] ,
497+ routes : [ { key : 'purpose' , name : SCREENS . ONBOARDING . PURPOSE } ] ,
498+ stale : false ,
499+ type : 'root' ,
500+ } ;
501+
502+ const resetToHome : NavigationAction = {
503+ type : CONST . NAVIGATION_ACTIONS . RESET ,
504+ payload : {
505+ key : 'root' ,
506+ index : 0 ,
507+ routeNames : [ SCREENS . HOME ] ,
508+ routes : [ { key : 'home' , name : SCREENS . HOME } ] ,
509+ stale : false ,
510+ type : 'root' ,
511+ } ,
512+ } ;
513+
514+ const result = OnboardingGuard . evaluate ( onboardingRootState , resetToHome , authenticatedContext ) as { type : 'BLOCK' ; reason ?: string } ;
515+
516+ // Then the RESET should still be blocked by shouldPreventReset (runs before the new check)
517+ expect ( result . type ) . toBe ( 'BLOCK' ) ;
518+ expect ( result . reason ) . toBe ( 'Cannot reset to non-onboarding screen while on onboarding' ) ;
519+ } ) ;
451520 } ) ;
452521} ) ;
0 commit comments