From ca1b2eb393f46d6f9744203cec825b14ae67303d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 18 Mar 2026 16:03:58 +0100 Subject: [PATCH 1/4] Fix report RHP immediately closing on second FAB create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirm modal pushed a competing browser history entry via shouldHandleNavigationBack: true (hardcoded in useConfirmModal). When the FAB popover's deferred history.back() fired, it popped the confirm modal's entry instead of its own, instantly closing it. Pass shouldHandleNavigationBack: false so the confirm modal skips history management — the FAB popover already handles back navigation. Fixes https://github.com/Expensify/App/issues/85661 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useCreateEmptyReportConfirmation.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useCreateEmptyReportConfirmation.tsx b/src/hooks/useCreateEmptyReportConfirmation.tsx index 5c71cf309c58c..402823e75fd0f 100644 --- a/src/hooks/useCreateEmptyReportConfirmation.tsx +++ b/src/hooks/useCreateEmptyReportConfirmation.tsx @@ -79,6 +79,7 @@ export default function useCreateEmptyReportConfirmation({policyName, onConfirm, title: `${translate('report.newReport.emptyReportConfirmationTitle')} `, confirmText: translate('report.newReport.createReport'), cancelText: translate('common.cancel'), + shouldHandleNavigationBack: false, prompt: ( Date: Wed, 18 Mar 2026 16:32:06 +0100 Subject: [PATCH 2/4] Fix FAB keyboard highlight persisting across open/close Reset focusedIndex on close so arrow-key highlight doesn't carry over when reopening the FAB menu. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index d60334b041d8a..8310371221802 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -74,6 +74,11 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio isActive: isVisible, }); + const handleClose = () => { + setFocusedIndex(-1); + onClose(); + }; + const onItemPress = (onSelected: () => void, options?: {shouldCallAfterModalHide?: boolean}) => { onItemSelected(); if (options?.shouldCallAfterModalHide && !isSafari()) { @@ -105,7 +110,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} - onClose={onClose} + onClose={handleClose} isVisible={isVisible} fromSidebarMediumScreen={!shouldUseNarrowLayout} animationIn="fadeIn" From 872f0349a190d5eb5c7596259078cce90aedc3c7 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 18 Mar 2026 16:58:37 +0100 Subject: [PATCH 3/4] Fix FAB Test Drive doing nothing on click Remove InteractionManager.runAfterInteractions() wrapper from startTestDrive that blocked navigation when the FAB popover's fadeOut animation created an interaction handle. Also collapse dead conditional where both branches navigated to the same route, and clean up unused params/imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/Tour.ts | 22 +---- src/pages/Search/EmptySearchView.tsx | 20 +--- .../menuItems/TestDriveMenuItem.tsx | 8 +- tests/actions/TourTest.ts | 91 +------------------ 4 files changed, 11 insertions(+), 130 deletions(-) diff --git a/src/libs/actions/Tour.ts b/src/libs/actions/Tour.ts index 5f524e3f6ceab..8829ac98e1a65 100644 --- a/src/libs/actions/Tour.ts +++ b/src/libs/actions/Tour.ts @@ -1,26 +1,8 @@ -import {InteractionManager} from 'react-native'; import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {IntroSelected} from './Report'; -function startTestDrive(introSelected: IntroSelected | undefined, hasUserBeenAddedToNudgeMigration: boolean, isUserPaidPolicyMember: boolean) { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - const shouldNavigateToDemo = - hasUserBeenAddedToNudgeMigration || - isUserPaidPolicyMember || - introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM || - introSelected?.choice === CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER || - introSelected?.choice === CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE || - (introSelected?.choice === CONST.ONBOARDING_CHOICES.SUBMIT && introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.WORKSPACE); - - if (shouldNavigateToDemo) { - Navigation.navigate(ROUTES.TEST_DRIVE_DEMO_ROOT); - return; - } - Navigation.navigate(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); +function startTestDrive() { + Navigation.navigate(ROUTES.TEST_DRIVE_DEMO_ROOT); } // eslint-disable-next-line import/prefer-default-export diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 05eba3789520b..4ea48132f66c1 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,4 +1,4 @@ -import {hasSeenTourSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; import {accountIDSelector} from '@selectors/Session'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; @@ -19,7 +19,6 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasEmptyReportsForPolicy from '@hooks/useHasEmptyReportsForPolicy'; -import useIsPaidPolicyAdmin from '@hooks/useIsPaidPolicyAdmin'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -40,7 +39,7 @@ import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {IntroSelected, PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; +import type {PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import useSearchEmptyStateIllustration from './useSearchEmptyStateIllustration'; @@ -55,10 +54,8 @@ type EmptySearchViewContentProps = EmptySearchViewProps & { currentUserPersonalDetails: PersonalDetails; typeMenuSections: SearchTypeMenuSection[]; allPolicies: OnyxCollection; - isUserPaidPolicyMember: boolean; activePolicy: OnyxEntry; groupPoliciesWithChatEnabled: readonly never[] | Array>; - introSelected: OnyxEntry; hasSeenTour: boolean; }; @@ -84,13 +81,10 @@ function EmptySearchView({similarSearchHash, type, hasResults, queryJSON}: Empty const groupPoliciesWithChatEnabled = getGroupPaidPoliciesWithExpenseChatEnabled(allPolicies); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); - const isUserPaidPolicyMember = useIsPaidPolicyAdmin(); - return ( @@ -124,10 +116,8 @@ function EmptySearchViewContent({ currentUserPersonalDetails, typeMenuSections, allPolicies, - isUserPaidPolicyMember, activePolicy, groupPoliciesWithChatEnabled, - introSelected, hasSeenTour, queryJSON, }: EmptySearchViewContentProps) { @@ -154,10 +144,6 @@ function EmptySearchViewContent({ selector: hasExpenseReportsSelector, }); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, { - selector: tryNewDotOnyxSelector, - }); - const shouldRedirectToExpensifyClassic = areAllGroupPoliciesExpenseChatDisabled(allPolicies ?? {}); const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); @@ -245,7 +231,7 @@ function EmptySearchViewContent({ const defaultViewItemHeader = useSearchEmptyStateIllustration(); const startTestDriveAction = () => { - startTestDrive(introSelected, tryNewDot?.hasBeenAddedToNudgeMigration ?? false, isUserPaidPolicyMember); + startTestDrive(); }; let content: EmptySearchViewItem | undefined; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TestDriveMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TestDriveMenuItem.tsx index bfaa7514f9967..ca7eb4fa3a389 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TestDriveMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TestDriveMenuItem.tsx @@ -1,6 +1,5 @@ -import {hasSeenTourSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; -import useIsPaidPolicyAdmin from '@hooks/useIsPaidPolicyAdmin'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -20,9 +19,6 @@ function TestDriveMenuItem() { const theme = useTheme(); const icons = useMemoizedLazyExpensifyIcons(['Binoculars'] as const); const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: tryNewDotOnyxSelector}); - const isUserPaidPolicyMember = useIsPaidPolicyAdmin(); const isVisible = !hasSeenTour; return ( @@ -34,7 +30,7 @@ function TestDriveMenuItem() { iconStyles={styles.popoverIconCircle} iconFill={theme.icon} title={translate('testDrive.quickAction.takeATwoMinuteTestDrive')} - onPress={() => interceptAnonymousUser(() => startTestDrive(introSelected, tryNewDot?.hasBeenAddedToNudgeMigration ?? false, isUserPaidPolicyMember))} + onPress={() => interceptAnonymousUser(() => startTestDrive())} /> ); } diff --git a/tests/actions/TourTest.ts b/tests/actions/TourTest.ts index 63355e80b3a22..cb7c45aa461a1 100644 --- a/tests/actions/TourTest.ts +++ b/tests/actions/TourTest.ts @@ -2,15 +2,10 @@ import Onyx from 'react-native-onyx'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {startTestDrive} from '@libs/actions/Tour'; import Navigation from '@libs/Navigation/Navigation'; -import Parser from '@libs/Parser'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; -import CONST from '@src/CONST'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OnboardingPurpose, Report, ReportAction} from '@src/types/onyx'; -import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -36,89 +31,11 @@ describe('actions/Tour', () => { }); describe('startTestDrive', () => { - describe('migrated users', () => { - it('should show the Test Drive demo if user has been nudged to migrate', async () => { - startTestDrive(undefined, true, false); - await waitForBatchedUpdates(); + it('should navigate to the Test Drive demo screen', async () => { + startTestDrive(); + await waitForBatchedUpdates(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); - - it("should show the Test Drive demo if user doesn't have the nudge flag but is member of a paid policy", async () => { - startTestDrive(undefined, false, true); - await waitForBatchedUpdates(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); - }); - - describe('NewDot users', () => { - const onboardingChoices = Object.values(CONST.ONBOARDING_CHOICES); - const onboardingDemoChoices = new Set([ - CONST.ONBOARDING_CHOICES.MANAGE_TEAM, - CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER, - CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, - ]); - const accountID = 2; - const conciergeChatReport: Report = LHNTestUtils.getFakeReport([accountID, CONST.ACCOUNT_ID.CONCIERGE]); - const testDriveTaskReport: Report = {...LHNTestUtils.getFakeReport(), ownerAccountID: accountID}; - - let testDriveTaskAction: ReportAction; - const setTestDriveTaskData = async () => { - testDriveTaskAction = { - ...LHNTestUtils.getFakeReportAction(), - childType: CONST.REPORT.TYPE.TASK, - childReportName: Parser.replace( - TestHelper.translateLocal('onboarding.testDrive.name', {testDriveURL: `${CONST.STAGING_NEW_EXPENSIFY_URL}/${ROUTES.TEST_DRIVE_DEMO_ROOT}`}), - ), - childReportID: testDriveTaskReport.reportID, - }; - - const reportActionsCollectionDataSet: ReportActionsCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeChatReport.reportID}`]: { - [testDriveTaskAction.reportActionID]: testDriveTaskAction, - }, - }; - - await Onyx.multiSet({ - ...reportActionsCollectionDataSet, - [ONYXKEYS.SESSION]: { - accountID, - }, - }); - }; - - it.each(onboardingChoices.filter((choice) => onboardingDemoChoices.has(choice)))('should show the Test Drive demo if user has "%s" onboarding choice', async (choice) => { - await setTestDriveTaskData(); - - startTestDrive({choice}, false, false); - await waitForBatchedUpdates(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); - - it.each(onboardingChoices.filter((choice) => !onboardingDemoChoices.has(choice)))('should show the Test Drive demo if user has "%s" onboarding choice', async (choice) => { - startTestDrive({choice}, false, false); - await waitForBatchedUpdates(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); - - it('should show the Test Drive demo if user is an invited employee', async () => { - await setTestDriveTaskData(); - - startTestDrive({choice: CONST.ONBOARDING_CHOICES.SUBMIT, inviteType: CONST.ONBOARDING_INVITE_TYPES.WORKSPACE}, false, false); - await waitForBatchedUpdates(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); - - it('should show the Test Drive demo if user is member of a paid policy', async () => { - startTestDrive({choice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND}, false, true); - await waitForBatchedUpdates(); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); - }); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_DEMO_ROOT); }); }); }); From 6451a9e19ff28763dc2338a76f29e6831947943b Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 18 Mar 2026 18:09:21 +0100 Subject: [PATCH 4/4] Scope shouldHandleNavigationBack override to FAB call site only The previous fix hardcoded shouldHandleNavigationBack: false in the shared useCreateEmptyReportConfirmation hook, which broke browser-back dismissal for all callers (search, workspace selection, compose bar). Make it an opt-in parameter (default: true) and only disable it from the FAB's CreateReportMenuItem. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useCreateEmptyReportConfirmation.tsx | 11 +++++++++-- .../menuItems/CreateReportMenuItem.tsx | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/hooks/useCreateEmptyReportConfirmation.tsx b/src/hooks/useCreateEmptyReportConfirmation.tsx index 402823e75fd0f..af9590271bccf 100644 --- a/src/hooks/useCreateEmptyReportConfirmation.tsx +++ b/src/hooks/useCreateEmptyReportConfirmation.tsx @@ -21,6 +21,8 @@ type UseCreateEmptyReportConfirmationParams = { onConfirm: (shouldDismissEmptyReportsConfirmation: boolean) => void; /** Optional callback function to execute when user cancels the confirmation */ onCancel?: () => void; + /** Whether the modal should push a history entry so browser-back dismisses it (default: true) */ + shouldHandleNavigationBack?: boolean; }; type UseCreateEmptyReportConfirmationResult = { @@ -54,7 +56,12 @@ function ConfirmationPrompt({workspaceName, checkboxRef, onLinkPress}: {workspac ); } -export default function useCreateEmptyReportConfirmation({policyName, onConfirm, onCancel}: UseCreateEmptyReportConfirmationParams): UseCreateEmptyReportConfirmationResult { +export default function useCreateEmptyReportConfirmation({ + policyName, + onConfirm, + onCancel, + shouldHandleNavigationBack = true, +}: UseCreateEmptyReportConfirmationParams): UseCreateEmptyReportConfirmationResult { const {translate} = useLocalize(); const {showConfirmModal, closeModal} = useConfirmModal(); const workspaceDisplayName = policyName?.trim().length ? policyName : translate('report.newReport.genericWorkspaceName'); @@ -79,7 +86,7 @@ export default function useCreateEmptyReportConfirmation({policyName, onConfirm, title: `${translate('report.newReport.emptyReportConfirmationTitle')} `, confirmText: translate('report.newReport.createReport'), cancelText: translate('common.cancel'), - shouldHandleNavigationBack: false, + shouldHandleNavigationBack, prompt: (