diff --git a/src/hooks/useDismissOnMoneyRequestReportRemoval.ts b/src/hooks/useDismissOnMoneyRequestReportRemoval.ts new file mode 100644 index 000000000000..fbd26c203213 --- /dev/null +++ b/src/hooks/useDismissOnMoneyRequestReportRemoval.ts @@ -0,0 +1,42 @@ +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; +import {isMoneyRequestReport} from '@libs/ReportUtils'; +import Navigation from '@navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; + +/** + * Dismisses the modal when a money request report is removed (e.g. deleted or merged). + * Skips dismissal during route changes — the new report's data may not be loaded yet, + * so the absent `report` should not be interpreted as removal. + */ +function useDismissOnMoneyRequestReportRemoval(reportIDFromRoute: string | undefined) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); + const prevReport = usePrevious(report); + const prevReportIDFromRoute = usePrevious(reportIDFromRoute); + const isFocused = useIsFocused(); + const firstRenderRef = useRef(true); + + useEffect(() => { + if (firstRenderRef.current) { + firstRenderRef.current = false; + return; + } + + if (prevReportIDFromRoute !== reportIDFromRoute) { + return; + } + + const isRemovalExpectedForReportType = !report && isMoneyRequestReport(prevReport); + + if (isRemovalExpectedForReportType) { + if (!isFocused) { + return; + } + Navigation.dismissModal(); + } + }, [report, isFocused, prevReport, prevReportIDFromRoute, reportIDFromRoute]); +} + +export default useDismissOnMoneyRequestReportRemoval; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index d467087a44c0..b67fb6248ca0 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -13,6 +13,7 @@ import useShowSuperWideRHPVersion from '@components/WideRHPContextProvider/useSh import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; import useActionListContextValue from '@hooks/useActionListContextValue'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useNetwork from '@hooks/useNetwork'; @@ -39,7 +40,7 @@ import { isMoneyRequestAction, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; -import {isMoneyRequestReport, isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils'; +import {isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils'; import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {doesDeleteNavigateBackUrlIncludeDuplicatesReview, getParentReportActionDeletionStatus, hasLoadedReportActions, isThreadReportDeleted} from '@libs/TransactionNavigationUtils'; import Navigation from '@navigation/Navigation'; @@ -72,8 +73,6 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const {currentSearchResults: snapshot} = useSearchStateContext(); - const firstRenderRef = useRef(true); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); @@ -86,34 +85,10 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { ); const [parentReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.parentReportID}`); - const prevReport = usePrevious(report); - const prevReportIDFromRoute = usePrevious(reportIDFromRoute); const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isFocused = useIsFocused(); - // Dismiss modal when the money request report is removed (e.g. deleted or merged). - useEffect(() => { - // Skip first run so we don't dismiss on mount when report may still be loading. - if (firstRenderRef.current) { - firstRenderRef.current = false; - return; - } - - // Route just changed — new report data may not be loaded yet, so don't treat as removal. - if (prevReportIDFromRoute !== reportIDFromRoute) { - return; - } - - // Report is gone now but we had a money request report before → it was removed. - const isRemovalExpectedForReportType = !report && isMoneyRequestReport(prevReport); - - if (isRemovalExpectedForReportType) { - if (!isFocused) { - return; - } - Navigation.dismissModal(); - } - }, [report, isFocused, prevReport, prevReportIDFromRoute, reportIDFromRoute]); + useDismissOnMoneyRequestReportRemoval(reportIDFromRoute); useEffect(() => { // Update last visit time when the expense super wide RHP report is focused @@ -210,6 +185,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { // Tracks initial mount to ensure openReport is called once for multi-transaction reports const isInitialMountRef = useRef(true); + const prevReportIDFromRoute = usePrevious(reportIDFromRoute); useEffect(() => { // Reset flag when reportID changes (screen stays mounted but navigates to different report) diff --git a/tests/unit/hooks/useDismissOnMoneyRequestReportRemoval.test.ts b/tests/unit/hooks/useDismissOnMoneyRequestReportRemoval.test.ts new file mode 100644 index 000000000000..41f8fa6bb3e3 --- /dev/null +++ b/tests/unit/hooks/useDismissOnMoneyRequestReportRemoval.test.ts @@ -0,0 +1,174 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const mockUseIsFocused = jest.fn().mockReturnValue(true); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => mockUseIsFocused() as boolean, + }; +}); + +jest.mock('@navigation/Navigation', () => ({ + dismissModal: jest.fn(), +})); + +const REPORT_A_ID = '1'; +const REPORT_B_ID = '2'; + +function buildMoneyRequestReport(id: string, overrides?: Partial): Report { + return { + reportID: id, + type: CONST.REPORT.TYPE.EXPENSE, + chatType: undefined, + ...overrides, + } as Report; +} + +function buildChatReport(id: string): Report { + return { + reportID: id, + type: CONST.REPORT.TYPE.CHAT, + } as Report; +} + +describe('useDismissOnMoneyRequestReportRemoval', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('dismisses the modal when a focused money request report is removed', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID)); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_A_ID}); + + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss the modal on first render even if the report is missing', async () => { + renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + await waitForBatchedUpdates(); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('does not dismiss the modal when the screen is unfocused', async () => { + mockUseIsFocused.mockReturnValue(false); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID)); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_A_ID}); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('does not dismiss the modal when the previous report was not a money request report', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildChatReport(REPORT_A_ID)); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_A_ID}); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + // Regression for https://github.com/Expensify/App/pull/89150 — navigating between reports with the + // prev/next arrows in the search/saved-search RHP should not dismiss the modal when the next report's + // data has not yet loaded into Onyx. + it('does not dismiss the modal when the route changes to a report whose data has not loaded yet', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID)); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + rerender({reportID: REPORT_B_ID}); + await waitForBatchedUpdates(); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + // Reviewer scenario: while navigating with the arrows in a saved/canned search, the previous report's + // status changes (e.g. open → processing) on the backend. The arrows must keep working — the modal must + // not be dismissed in any of the intermediate render passes. + it('keeps the arrows working when a report status changes mid-navigation in a saved search', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN})); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + // Status change on the previous report (still defined, just mutated). + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED}); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_A_ID}); + + // Arrow forward — report B is not in Onyx yet. + rerender({reportID: REPORT_B_ID}); + await waitForBatchedUpdates(); + + // Report B data arrives. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_B_ID}`, buildMoneyRequestReport(REPORT_B_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN})); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_B_ID}); + + // Arrow back — report A is still around. + rerender({reportID: REPORT_A_ID}); + await waitForBatchedUpdates(); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('does not dismiss the modal when the report is updated but still present', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN})); + await waitForBatchedUpdates(); + + const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), { + initialProps: {reportID: REPORT_A_ID}, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}); + await waitForBatchedUpdates(); + rerender({reportID: REPORT_A_ID}); + + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); +});