Skip to content

Commit 26397b5

Browse files
authored
Merge pull request #89029 from Expensify/claude-migratedUserWelcomeModalV2
Show migrated user welcome modal immediately (v2, race condition fix)
2 parents 2674a53 + 25d7631 commit 26397b5

4 files changed

Lines changed: 179 additions & 5 deletions

File tree

src/components/MigratedUserWelcomeModal.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import {openExternalLink} from '@libs/actions/Link';
1111
import {dismissProductTraining} from '@libs/actions/Welcome';
1212
import convertToLTR from '@libs/convertToLTR';
1313
import Log from '@libs/Log';
14+
import Navigation from '@libs/Navigation/Navigation';
15+
import {buildCannedSearchQuery} from '@libs/SearchQueryUtils';
1416
import variables from '@styles/variables';
1517
import CONST from '@src/CONST';
18+
import ROUTES from '@src/ROUTES';
1619
import type {FeatureListItem} from './FeatureList';
1720
import FeatureTrainingModal from './FeatureTrainingModal';
1821
import Icon from './Icon';
@@ -58,6 +61,7 @@ function MigratedUserWelcomeModal() {
5861
const onClose = () => {
5962
Log.hmmm('[MigratedUserWelcomeModal] onClose called, dismissing product training');
6063
dismissProductTraining(CONST.MIGRATED_USER_WELCOME_MODAL);
64+
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT})}));
6165
};
6266

6367
const featureListContent = (

src/libs/Navigation/guards/MigratedUserWelcomeModalGuard.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,79 @@ import {tryNewDotOnyxSelector} from '@selectors/Onboarding';
44
import Onyx from 'react-native-onyx';
55
import type {OnyxEntry} from 'react-native-onyx';
66
import Log from '@libs/Log';
7+
import Navigation from '@libs/Navigation/Navigation';
78
import isProductTrainingElementDismissed from '@libs/TooltipUtils';
89
import CONST from '@src/CONST';
910
import NAVIGATORS from '@src/NAVIGATORS';
1011
import ONYXKEYS from '@src/ONYXKEYS';
1112
import ROUTES from '@src/ROUTES';
1213
import SCREENS from '@src/SCREENS';
13-
import type {DismissedProductTraining} from '@src/types/onyx';
14+
import type {DismissedProductTraining, Session} from '@src/types/onyx';
1415
import type {GuardResult, NavigationGuard} from './types';
1516

1617
let hasBeenAddedToNudgeMigration = false;
1718
let dismissedProductTraining: OnyxEntry<DismissedProductTraining>;
19+
let isDismissedProductTrainingLoaded = false;
20+
let session: OnyxEntry<Session>;
21+
let isLoadingApp = true;
1822

1923
let hasRedirectedToMigratedUserModal = false;
2024

2125
function resetSessionFlag() {
2226
hasRedirectedToMigratedUserModal = false;
2327
}
2428

29+
/**
30+
* Proactively navigate to the migrated user welcome modal when all conditions are met,
31+
* without waiting for a user-initiated navigation action.
32+
* Waits for NVP_DISMISSED_PRODUCT_TRAINING to load before evaluating, preventing the
33+
* race condition where the modal would re-appear on app restart.
34+
*/
35+
function navigateToMigratedUserWelcomeModalIfReady() {
36+
if (
37+
!session?.authToken ||
38+
isLoadingApp ||
39+
hasRedirectedToMigratedUserModal ||
40+
!hasBeenAddedToNudgeMigration ||
41+
!isDismissedProductTrainingLoaded ||
42+
isProductTrainingElementDismissed('migratedUserWelcomeModal', dismissedProductTraining)
43+
) {
44+
return;
45+
}
46+
47+
Log.info('[MigratedUserWelcomeModalGuard] Proactively navigating to migrated user welcome modal');
48+
hasRedirectedToMigratedUserModal = true;
49+
Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute());
50+
}
51+
52+
/**
53+
* Called by guards/index.ts when session or loading app state changes.
54+
* Reuses the shared Onyx subscriptions from guards/index.ts to avoid duplicate connections.
55+
*/
56+
function onSessionOrLoadingAppChanged(sessionValue: OnyxEntry<Session>, isLoadingAppValue: boolean) {
57+
session = sessionValue;
58+
isLoadingApp = isLoadingAppValue;
59+
navigateToMigratedUserWelcomeModalIfReady();
60+
}
61+
2562
Onyx.connectWithoutView({
2663
key: ONYXKEYS.NVP_TRY_NEW_DOT,
2764
callback: (value) => {
2865
const result = value ? tryNewDotOnyxSelector(value) : undefined;
2966
hasBeenAddedToNudgeMigration = result?.hasBeenAddedToNudgeMigration ?? false;
67+
navigateToMigratedUserWelcomeModalIfReady();
3068
},
3169
});
3270

3371
Onyx.connectWithoutView({
3472
key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
3573
callback: (value) => {
3674
dismissedProductTraining = value;
75+
isDismissedProductTrainingLoaded = true;
3776
if (isProductTrainingElementDismissed('migratedUserWelcomeModal', value)) {
3877
hasRedirectedToMigratedUserModal = false;
3978
}
79+
navigateToMigratedUserWelcomeModalIfReady();
4080
},
4181
});
4282

@@ -98,4 +138,4 @@ const MigratedUserWelcomeModalGuard: NavigationGuard = {
98138
};
99139

100140
export default MigratedUserWelcomeModalGuard;
101-
export {resetSessionFlag};
141+
export {resetSessionFlag, onSessionOrLoadingAppChanged};

src/libs/Navigation/guards/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
44
import getCurrentUrl from '@libs/Navigation/currentUrl';
55
import ONYXKEYS from '@src/ONYXKEYS';
66
import type {Session} from '@src/types/onyx';
7-
import MigratedUserWelcomeModalGuard from './MigratedUserWelcomeModalGuard';
7+
import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged} from './MigratedUserWelcomeModalGuard';
88
import OnboardingGuard from './OnboardingGuard';
99
import type {GuardContext, GuardResult, NavigationGuard} from './types';
1010

@@ -19,13 +19,15 @@ Onyx.connectWithoutView({
1919
key: ONYXKEYS.SESSION,
2020
callback: (value) => {
2121
session = value;
22+
onSessionOrLoadingAppChanged(session, isLoadingApp);
2223
},
2324
});
2425

2526
Onyx.connectWithoutView({
2627
key: ONYXKEYS.IS_LOADING_APP,
2728
callback: (value) => {
2829
isLoadingApp = value ?? true;
30+
onSessionOrLoadingAppChanged(session, isLoadingApp);
2931
},
3032
});
3133

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

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {NavigationAction, NavigationState} from '@react-navigation/native';
22
import Onyx from 'react-native-onyx';
3-
import MigratedUserWelcomeModalGuard, {resetSessionFlag} from '@libs/Navigation/guards/MigratedUserWelcomeModalGuard';
3+
import MigratedUserWelcomeModalGuard, {onSessionOrLoadingAppChanged, resetSessionFlag} from '@libs/Navigation/guards/MigratedUserWelcomeModalGuard';
44
import type {GuardContext} from '@libs/Navigation/guards/types';
55
import CONST from '@src/CONST';
66
import NAVIGATORS from '@src/NAVIGATORS';
@@ -9,6 +9,13 @@ import ROUTES from '@src/ROUTES';
99
import SCREENS from '@src/SCREENS';
1010
import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates';
1111

12+
const mockNavigate = jest.fn();
13+
jest.mock('@libs/Navigation/Navigation', () => ({
14+
navigate: (...args: unknown[]) => {
15+
mockNavigate(...args);
16+
},
17+
}));
18+
1219
describe('MigratedUserWelcomeModalGuard', () => {
1320
const mockState: NavigationState = {
1421
key: 'root',
@@ -31,8 +38,12 @@ describe('MigratedUserWelcomeModalGuard', () => {
3138
};
3239

3340
beforeEach(async () => {
34-
await Onyx.clear();
41+
// Reset module-level session/loading state first so stale values from
42+
// previous tests don't trigger navigation during Onyx.clear() callbacks
43+
onSessionOrLoadingAppChanged(undefined, true);
3544
resetSessionFlag();
45+
mockNavigate.mockClear();
46+
await Onyx.clear();
3647
await waitForBatchedUpdates();
3748
});
3849

@@ -324,4 +335,121 @@ describe('MigratedUserWelcomeModalGuard', () => {
324335
expect(result.type).not.toBe('BLOCK');
325336
});
326337
});
338+
339+
describe('onSessionOrLoadingAppChanged (proactive navigation)', () => {
340+
it('should navigate when all conditions are met (session, not loading, nudge migration, training loaded, not dismissed)', async () => {
341+
// Set up nudge migration (session is not set yet, so no navigation here)
342+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
343+
nudgeMigration: {
344+
timestamp: new Date(),
345+
cohort: 'test',
346+
},
347+
});
348+
await waitForBatchedUpdates();
349+
mockNavigate.mockClear();
350+
351+
// Now signal that session is ready and app is done loading
352+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
353+
354+
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute());
355+
});
356+
357+
it('should not navigate when app is still loading', async () => {
358+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
359+
nudgeMigration: {
360+
timestamp: new Date(),
361+
cohort: 'test',
362+
},
363+
});
364+
await waitForBatchedUpdates();
365+
mockNavigate.mockClear();
366+
367+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, true);
368+
369+
expect(mockNavigate).not.toHaveBeenCalled();
370+
});
371+
372+
it('should not navigate when there is no session', async () => {
373+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
374+
nudgeMigration: {
375+
timestamp: new Date(),
376+
cohort: 'test',
377+
},
378+
});
379+
await waitForBatchedUpdates();
380+
mockNavigate.mockClear();
381+
382+
onSessionOrLoadingAppChanged(undefined, false);
383+
384+
expect(mockNavigate).not.toHaveBeenCalled();
385+
});
386+
387+
it('should not navigate when user has not been added to nudge migration', async () => {
388+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
389+
nudgeMigration: null,
390+
});
391+
await waitForBatchedUpdates();
392+
mockNavigate.mockClear();
393+
394+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
395+
396+
expect(mockNavigate).not.toHaveBeenCalled();
397+
});
398+
399+
it('should not navigate when modal was already dismissed', async () => {
400+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
401+
nudgeMigration: {
402+
timestamp: new Date(),
403+
cohort: 'test',
404+
},
405+
});
406+
await Onyx.merge(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {
407+
migratedUserWelcomeModal: {
408+
timestamp: new Date().toISOString(),
409+
dismissedMethod: 'click',
410+
},
411+
});
412+
await waitForBatchedUpdates();
413+
mockNavigate.mockClear();
414+
415+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
416+
417+
expect(mockNavigate).not.toHaveBeenCalled();
418+
});
419+
420+
it('should only navigate once even if called multiple times', async () => {
421+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
422+
nudgeMigration: {
423+
timestamp: new Date(),
424+
cohort: 'test',
425+
},
426+
});
427+
await waitForBatchedUpdates();
428+
mockNavigate.mockClear();
429+
430+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
431+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
432+
433+
expect(mockNavigate).toHaveBeenCalledTimes(1);
434+
});
435+
436+
it('should navigate via Onyx NVP_DISMISSED_PRODUCT_TRAINING callback when session is already set', async () => {
437+
// Set session first
438+
onSessionOrLoadingAppChanged({authToken: 'test-token', accountID: 123}, false);
439+
mockNavigate.mockClear();
440+
441+
// Set up nudge migration and dismissed product training (modal not dismissed)
442+
await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, {
443+
nudgeMigration: {
444+
timestamp: new Date(),
445+
cohort: 'test',
446+
},
447+
});
448+
await waitForBatchedUpdates();
449+
450+
// The NVP_TRY_NEW_DOT callback triggers navigateToMigratedUserWelcomeModalIfReady
451+
// which should navigate because all conditions are met
452+
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute());
453+
});
454+
});
327455
});

0 commit comments

Comments
 (0)