Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/hooks/useDismissOnMoneyRequestReportRemoval.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
TMisiukiewicz marked this conversation as resolved.
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;
32 changes: 4 additions & 28 deletions src/pages/Search/SearchMoneyRequestReportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Comment thread
TMisiukiewicz marked this conversation as resolved.
useEffect(() => {
// Update last visit time when the expense super wide RHP report is focused
Expand Down Expand Up @@ -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)
Expand Down
174 changes: 174 additions & 0 deletions tests/unit/hooks/useDismissOnMoneyRequestReportRemoval.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ReactNavigation>('@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>): 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();
});
});
Loading