diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 917c645a716c..6eba8a96fe5d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -366,8 +366,8 @@ const ONYXKEYS = { /** The end date (epoch timestamp) of the workspace owner’s grace period for an overdue Consolidated Travel Billing invoice. */ NVP_PRIVATE_OWNER_TRAVEL_BILLING_GRACE_PERIOD_END: 'nvp_private_travelBillingGracePeriodEnd', - /** The NVP containing the target url to navigate to when deleting a transaction */ - NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', + /** Set while a deleted transaction's screen is navigating away, holds the route it goes back to. Not found guards stay quiet while it's set */ + RAM_ONLY_PENDING_TRANSACTION_DELETE_URL: 'pendingTransactionDeleteURL', /** * Cutoff time for a full reconnect. If this client's last full reconnect @@ -2019,7 +2019,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_TRAVEL_BILLING_GRACE_PERIOD_END]: number; - [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; + [ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL]: string | undefined; [ONYXKEYS.NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE]: string; [ONYXKEYS.NVP_PRIVATE_FIRST_POLICY_CREATED_DATE]: string; [ONYXKEYS.NVP_PRIVATE_MANUAL_TEAM_2025_PRICING]: string; diff --git a/src/components/ReportOrNotFoundGuard.tsx b/src/components/ReportOrNotFoundGuard.tsx index 3852d3671070..49fa77ff2669 100644 --- a/src/components/ReportOrNotFoundGuard.tsx +++ b/src/components/ReportOrNotFoundGuard.tsx @@ -88,7 +88,7 @@ function ReportOrNotFoundGuard({reportID, shouldRequireReportID = true, children const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const hasGuidesEmails = hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), guideAccountIDs); const isFocused = useIsFocused(); diff --git a/src/hooks/useClearDeleteTransactionNavigateBackUrlOnUnmount.ts b/src/hooks/useClearDeleteTransactionNavigateBackUrlOnUnmount.ts new file mode 100644 index 000000000000..3ec2ae60add4 --- /dev/null +++ b/src/hooks/useClearDeleteTransactionNavigateBackUrlOnUnmount.ts @@ -0,0 +1,24 @@ +import {doesDeleteNavigateBackUrlIncludeDuplicatesReview} from '@libs/TransactionNavigationUtils'; + +import {clearDeleteTransactionNavigateBackUrlAfterTransitions} from '@userActions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect} from 'react'; +import Onyx from 'react-native-onyx'; + +/** Clears the delete navigate back URL on unmount, since native stack never renders a popped screen unfocused. */ +function useClearDeleteTransactionNavigateBackUrlOnUnmount(shouldClear: boolean) { + useEffect(() => { + if (!shouldClear) { + return; + } + return () => + clearDeleteTransactionNavigateBackUrlAfterTransitions(() => + // Read when the clear fires, the unmounted screen may never have received the URL. + Onyx.get(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL).then((url) => !!url && !doesDeleteNavigateBackUrlIncludeDuplicatesReview(url)), + ); + }, [shouldClear]); +} + +export default useClearDeleteTransactionNavigateBackUrlOnUnmount; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 9d6daadb895a..4a45bb5de306 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -502,8 +502,6 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.NEW_GROUP_CHAT_DRAFT, ONYXKEYS.NVP_BILLING_FUND_ID, ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS, - // Holds a raw route, and a search route can carry an unencoded email in its "name" param - ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, ONYXKEYS.NVP_EXPENSE_RULES, ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, @@ -550,6 +548,8 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, + // Holds a raw route, and a search route can carry an unencoded email in its "name" param + ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c724f0bc1a78..5a9ea5601225 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3078,6 +3078,23 @@ function isReportTransactionThread(report: OnyxEntry) { return isMoneyRequest(report) || isTrackExpenseReport(report); } +/** + * Same as isReportTransactionThread, but takes the parent report and action so callers can subscribe to them + */ +function isReportTransactionThreadNew( + report: OnyxEntry>, + parentReport: OnyxEntry>, + parentReportAction: OnyxEntry, +) { + if (!report?.parentReportID || !report.parentReportActionID || isEmptyObject(parentReportAction)) { + return false; + } + if (parentReport?.type === CONST.REPORT.TYPE.IOU || parentReport?.type === CONST.REPORT.TYPE.EXPENSE) { + return isTransactionThread(parentReportAction); + } + return parentReport?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM && isTrackExpenseAction(parentReportAction); +} + /** * Get displayed report ID, it will be parentReportID if the report is one transaction thread */ @@ -16145,6 +16162,7 @@ export { isNewDotInvoice, isOpenInvoiceReport, isReportTransactionThread, + isReportTransactionThreadNew, getDefaultNotificationPreferenceForReport, canWriteInReport, navigateToDetailsPage, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index af139ae93925..03e4917799d9 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -85,6 +85,7 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTop import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; import Navigation from '@libs/Navigation/Navigation'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {getDBTimeWithSkew, getIsOffline as isOfflineNetwork, getServerAnchoredDBTime} from '@libs/NetworkState'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; @@ -7432,12 +7433,44 @@ function downloadReportPDF(fileName: string, reportName: string, translate: Loca fileDownload(translate, addEncryptedAuthTokenToURL(pdfURL, encryptedAuthToken, true), downloadFileName, '', Browser.isMobileSafari()).then(() => setDownload(fileName, false)); } +// A newer deletion cancels this so a stale clear can't wipe its URL. +let pendingDeleteTransactionNavigateBackUrlClear: {cancel: () => void} | undefined; + function setDeleteTransactionNavigateBackUrl(url: string) { - Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, url); + pendingDeleteTransactionNavigateBackUrlClear?.cancel(); + pendingDeleteTransactionNavigateBackUrlClear = undefined; + Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, url); } function clearDeleteTransactionNavigateBackUrl() { - Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null); + Onyx.merge(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, null); +} + +function clearDeleteTransactionNavigateBackUrlAfterTransitions(shouldClear: () => Promise) { + pendingDeleteTransactionNavigateBackUrlClear?.cancel(); + let isCancelled = false; + let frameID: number | undefined; + const transitionHandle = TransitionTracker.runAfterTransitions({ + callback: () => { + shouldClear().then((shouldClearUrl) => { + if (isCancelled || !shouldClearUrl) { + return; + } + frameID = requestAnimationFrame(clearDeleteTransactionNavigateBackUrl); + }); + }, + waitForUpcomingTransition: true, + }); + pendingDeleteTransactionNavigateBackUrlClear = { + cancel: () => { + isCancelled = true; + transitionHandle.cancel(); + if (frameID === undefined) { + return; + } + cancelAnimationFrame(frameID); + }, + }; } type DeleteAppReportProps = { @@ -9539,6 +9572,7 @@ export { clearAddRoomMemberError, clearAvatarErrors, clearDeleteTransactionNavigateBackUrl, + clearDeleteTransactionNavigateBackUrlAfterTransitions, clearIOUError, clearReportErrors, clearNewRoomFormError, diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 910670642dc1..9dd7d475559c 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -7,6 +7,7 @@ import {useSearchResultsContext} from '@components/Search/SearchContext'; import useRHPWidth from '@components/WideRHPContextProvider/useRHPWidth'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; +import useClearDeleteTransactionNavigateBackUrlOnUnmount from '@hooks/useClearDeleteTransactionNavigateBackUrlOnUnmount'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -85,7 +86,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { selector: fullPolicySnapshotLastModifiedSelector, }); const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, {selector: Boolean}); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); const parentReportAction = useParentReportAction(report); @@ -128,6 +129,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { return () => handle.cancel(); }, [isFocused, deleteTransactionNavigateBackUrl]); + useClearDeleteTransactionNavigateBackUrlOnUnmount(true); + const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const isReportArchived = useReportIsArchived(report?.reportID); diff --git a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx index b71ac25cfbdd..45390857d6f1 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx @@ -60,7 +60,7 @@ function DynamicReviewPage() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, {selector: Boolean}); const [parentReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.parentReportID}`); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${route.params.reportID}`); const [expenseReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); diff --git a/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx b/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx index 28bcf6aca7d3..e76ea6ba7459 100644 --- a/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx +++ b/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx @@ -1,23 +1,42 @@ +import useClearDeleteTransactionNavigateBackUrlOnUnmount from '@hooks/useClearDeleteTransactionNavigateBackUrlOnUnmount'; import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {isReportTransactionThreadNew} from '@libs/ReportUtils'; import {doesDeleteNavigateBackUrlIncludeDuplicatesReview} from '@libs/TransactionNavigationUtils'; import {clearDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; import {useEffect} from 'react'; +type DeleteTransactionNavigateBackHandlerProps = { + /** The reportID of the report screen this handler is mounted in */ + reportID: string | undefined; +}; + +const parentIDsSelector = (report: OnyxEntry) => report && {parentReportID: report.parentReportID, parentReportActionID: report.parentReportActionID}; +const typeAndChatTypeSelector = (report: OnyxEntry) => report && {type: report.type, chatType: report.chatType}; + /** - * Component that does not render anything but isolates the NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL + * Component that does not render anything but isolates the RAM_ONLY_PENDING_TRANSACTION_DELETE_URL * subscription from ReportScreen. Clears the URL after interactions complete * when the report is no longer focused. */ -function DeleteTransactionNavigateBackHandler() { +function DeleteTransactionNavigateBackHandler({reportID}: DeleteTransactionNavigateBackHandlerProps) { const isFocused = useIsFocused(); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); + const [reportParentIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`, {selector: parentIDsSelector}); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportParentIDs?.parentReportID)}`, {selector: typeAndChatTypeSelector}); + const parentReportAction = useParentReportAction(reportParentIDs); + const isTransactionThreadReport = isReportTransactionThreadNew(reportParentIDs, parentReport, parentReportAction); useEffect(() => { if (isFocused || !deleteTransactionNavigateBackUrl) { @@ -36,6 +55,8 @@ function DeleteTransactionNavigateBackHandler() { return () => handle.cancel(); }, [isFocused, deleteTransactionNavigateBackUrl]); + useClearDeleteTransactionNavigateBackUrlOnUnmount(isTransactionThreadReport); + return null; } diff --git a/src/pages/inbox/ReportNotFoundGuard.tsx b/src/pages/inbox/ReportNotFoundGuard.tsx index 075d760f9de6..ad884082141a 100644 --- a/src/pages/inbox/ReportNotFoundGuard.tsx +++ b/src/pages/inbox/ReportNotFoundGuard.tsx @@ -51,7 +51,7 @@ function ReportNotFoundGuard({children}: ReportNotFoundGuardProps) { }); const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); const reportID = report?.reportID; const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 89abccfddf72..a92744386c22 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -123,7 +123,7 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep testID={`report-screen-${reportIDFromRoute}`} > - + diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index e36a4584ef05..59581ceaef8e 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -87,7 +87,7 @@ export default function (shouldRequireReportID = true): { await waitForBatchedUpdatesWithAct(); // The page's own fallback must have produced the invoice-room back URL. - const backUrl = await getOnyxValue(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const backUrl = await getOnyxValue(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); expect(backUrl).toBe(ROUTES.REPORT_WITH_ID.getRoute(invoiceRoomID)); }); @@ -413,7 +413,7 @@ describe('DynamicReportDetailsPage', () => { // Simulate the invoice delete: set the delete-back URL to the invoice room, then null the invoice report // (the deferred optimistic delete) while this focused RHP is still mounted. await act(async () => { - await Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, ROUTES.REPORT_WITH_ID.getRoute(invoiceRoomID)); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, ROUTES.REPORT_WITH_ID.getRoute(invoiceRoomID)); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${invoiceReportID}`, null); }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/SearchMoneyRequestReportPageTest.tsx b/tests/ui/SearchMoneyRequestReportPageTest.tsx index 9d650322d807..e00228993dc1 100644 --- a/tests/ui/SearchMoneyRequestReportPageTest.tsx +++ b/tests/ui/SearchMoneyRequestReportPageTest.tsx @@ -77,6 +77,7 @@ jest.mock('@userActions/Report', () => ({ openReport: jest.fn(), updateLastVisitTime: jest.fn(), clearDeleteTransactionNavigateBackUrl: jest.fn(), + clearDeleteTransactionNavigateBackUrlAfterTransitions: jest.fn(), })); // All child components are mocked to null: none of them need to actually render for these tests, diff --git a/tests/unit/DeleteTransactionNavigateBackHandlerTest.tsx b/tests/unit/DeleteTransactionNavigateBackHandlerTest.tsx new file mode 100644 index 000000000000..081108fd1a7c --- /dev/null +++ b/tests/unit/DeleteTransactionNavigateBackHandlerTest.tsx @@ -0,0 +1,209 @@ +import {render} from '@testing-library/react-native'; + +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import DeleteTransactionNavigateBackHandler from '@pages/inbox/DeleteTransactionNavigateBackHandler'; + +import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import type * as Navigation from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { + callback(); + return {cancel: jest.fn()}; + }), +})); + +const THREAD_REPORT_ID = '1'; +const CHAT_REPORT_ID = '2'; +const EXPENSE_REPORT_ID = '3'; +const IOU_ACTION_ID = '4'; +const IOU_ACTION = { + reportActionID: IOU_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2026-01-01 00:00:00.000', + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the backend still sends originalMessage on report actions + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD', IOUTransactionID: '5'}, +} as ReportAction; +const REPORT_URL = 'r/123'; +const NEXT_REPORT_URL = 'r/456'; +const DUPLICATES_REVIEW_URL = 'r/123/duplicates/review'; + +async function flushClear() { + await waitForBatchedUpdates(); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + await waitForBatchedUpdates(); +} + +async function getNavigateBackUrl() { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, + callback: (value) => { + connection.unsubscribe(); + resolve(value); + }, + }); + }); +} + +describe('DeleteTransactionNavigateBackHandler', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${THREAD_REPORT_ID}`, {reportID: THREAD_REPORT_ID, parentReportID: EXPENSE_REPORT_ID, parentReportActionID: IOU_ACTION_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, {reportID: CHAT_REPORT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`, {[IOU_ACTION_ID]: IOU_ACTION}); + await waitForBatchedUpdates(); + }); + + it('clears the navigate back URL when the focused screen unmounts', async () => { + // Given a focused expense screen that was just deleted, so the URL is set while it is still focused + const {unmount} = render(); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, REPORT_URL); + await waitForBatchedUpdates(); + + // When the native stack pops it, it unmounts without ever rendering unfocused + unmount(); + await flushClear(); + + // Then the URL is cleared so it does not leak into the next expense + expect(await getNavigateBackUrl()).toBeUndefined(); + }); + + it('keeps the navigate back URL while the screen stays focused', async () => { + // Given a focused expense screen with the URL set + render(); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, REPORT_URL); + + // When nothing navigates away + await flushClear(); + + // Then the URL stays, so the deleted expense does not flash Not Found before the pop + expect(await getNavigateBackUrl()).toBe(REPORT_URL); + }); + + it('keeps the duplicates review URL when the screen unmounts', async () => { + // Given a focused screen whose URL points to duplicates review, which clears the URL by itself + const {unmount} = render(); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, DUPLICATES_REVIEW_URL); + await waitForBatchedUpdates(); + + // When the screen unmounts + unmount(); + await flushClear(); + + // Then the URL is left for the duplicates review flow + expect(await getNavigateBackUrl()).toBe(DUPLICATES_REVIEW_URL); + }); + + it('does not clear a newer deletion URL from a remount clear that is still waiting', async () => { + // Given a screen that remounts without a deletion, so its clear waits for a later transition + let pendingCallback: (() => void) | undefined; + let isCancelled = false; + jest.mocked(TransitionTracker.runAfterTransitions).mockImplementationOnce(({callback}) => { + pendingCallback = () => { + callback(); + }; + return { + cancel: () => { + isCancelled = true; + }, + }; + }); + const {unmount} = render(); + await waitForBatchedUpdates(); + unmount(); + + // When the next expense is deleted before that transition finishes + setDeleteTransactionNavigateBackUrl(NEXT_REPORT_URL); + await waitForBatchedUpdates(); + if (!isCancelled) { + pendingCallback?.(); + } + await flushClear(); + + // Then the stale clear is cancelled and the new URL stays until its own screen leaves + expect(isCancelled).toBe(true); + expect(await getNavigateBackUrl()).toBe(NEXT_REPORT_URL); + }); + + it('keeps the navigate back URL when a non transaction thread report unmounts', async () => { + // Given a chat report screen while a deletion URL is set + const {unmount} = render(); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, REPORT_URL); + await waitForBatchedUpdates(); + + // When that chat unmounts + unmount(); + await flushClear(); + + // Then the URL stays, since only a deleted transaction thread should clear it on unmount + expect(await getNavigateBackUrl()).toBe(REPORT_URL); + }); + + it('clears the navigate back URL when the parent data loads after the thread', async () => { + // Given a thread screen that mounts before its parent expense report and action are loaded + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, null); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`, null); + const {unmount} = render(); + await waitForBatchedUpdates(); + + // When the parent data arrives and then the deleted expense is popped + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`, {[IOU_ACTION_ID]: IOU_ACTION}); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, REPORT_URL); + await waitForBatchedUpdates(); + unmount(); + await flushClear(); + + // Then the screen is still treated as a transaction thread and the URL is cleared + expect(await getNavigateBackUrl()).toBeUndefined(); + }); + + it('does not clear a newer deletion URL from a clear whose frame is still queued', async () => { + // Given an unmount clear that already passed the transition and queued its animation frame + let queuedFrame: FrameRequestCallback | undefined; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementationOnce((callback) => { + queuedFrame = callback; + return 1; + }); + const cancelAnimationFrameSpy = jest.spyOn(global, 'cancelAnimationFrame'); + const {unmount} = render(); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, REPORT_URL); + await waitForBatchedUpdates(); + unmount(); + await waitForBatchedUpdates(); + expect(queuedFrame).toBeDefined(); + + // When the next expense is deleted before that frame runs + setDeleteTransactionNavigateBackUrl(NEXT_REPORT_URL); + await waitForBatchedUpdates(); + + // Then the queued frame is cancelled and the new URL stays + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1); + expect(await getNavigateBackUrl()).toBe(NEXT_REPORT_URL); + requestAnimationFrameSpy.mockRestore(); + cancelAnimationFrameSpy.mockRestore(); + }); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index a3f70348f2b3..521126e6d2b7 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -213,6 +213,7 @@ import { isReportManager, isReportOutstanding, isReportPendingDelete, + isReportTransactionThreadNew, isResolvedSupportTicket, isRootGroupChat, isSelfDMOrSelfDMThread, @@ -295,6 +296,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import type {OnyxCollection, OnyxEntry, OnyxKey, OnyxMergeCollectionInput} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {addDays, format as formatDate} from 'date-fns'; import Onyx from 'react-native-onyx'; @@ -31648,3 +31650,43 @@ describe('isExportInProgress', () => { expect(isExportInProgress(reportActions)).toBe(false); }); }); + +describe('isReportTransactionThreadNew', () => { + const thread = {parentReportID: '1', parentReportActionID: '2'}; + const buildIOUAction = (type: ValueOf) => + ({ + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the backend still sends originalMessage on report actions + originalMessage: {type, amount: 100, currency: 'USD', IOUTransactionID: '3'}, + }) as ReportAction; + + it('is true for an expense thread under an expense report', () => { + // Given a thread whose parent is an expense report and whose parent action created the expense + // When checking it with the parent data passed in + // Then it is a transaction thread, without reading the parent from Onyx + expect(isReportTransactionThreadNew(thread, {type: CONST.REPORT.TYPE.EXPENSE}, buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE))).toBe(true); + }); + + it('is true for a tracked expense thread under the self DM', () => { + // Given a thread whose parent is the self DM and whose parent action tracked the expense + // When checking it + // Then it is a transaction thread + expect(isReportTransactionThreadNew(thread, {chatType: CONST.REPORT.CHAT_TYPE.SELF_DM}, buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.TRACK))).toBe(true); + }); + + it('is false while the parent report or action is not loaded', () => { + // Given a thread whose parent data has not arrived yet + // When checking it + // Then it is not a transaction thread yet, so callers recheck once the parent loads + expect(isReportTransactionThreadNew(thread, undefined, buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE))).toBe(false); + expect(isReportTransactionThreadNew(thread, {type: CONST.REPORT.TYPE.EXPENSE}, undefined)).toBe(false); + }); + + it('is false for a report that is not a thread', () => { + // Given a report with no parent action + // When checking it + // Then it is not a transaction thread + expect(isReportTransactionThreadNew({parentReportID: '1'}, {type: CONST.REPORT.TYPE.EXPENSE}, buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE))).toBe(false); + }); +}); diff --git a/tests/unit/components/withReportOrNotFound.test.tsx b/tests/unit/components/withReportOrNotFound.test.tsx index fa96b446f98d..a809daaed871 100644 --- a/tests/unit/components/withReportOrNotFound.test.tsx +++ b/tests/unit/components/withReportOrNotFound.test.tsx @@ -205,7 +205,7 @@ describe('withReportOrNotFound and ReportOrNotFoundGuard', () => { await waitForBatchedUpdatesWithAct(); expect(screen.getByTestId('content')).toBeTruthy(); await act(async () => { - await Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, 'r/1'); + await Onyx.set(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL, 'r/1'); }); // When the report is removed diff --git a/tests/unit/hooks/useExpenseActionsInvoiceDeleteTest.tsx b/tests/unit/hooks/useExpenseActionsInvoiceDeleteTest.tsx index 8dc1e38aa134..544c24566eea 100644 --- a/tests/unit/hooks/useExpenseActionsInvoiceDeleteTest.tsx +++ b/tests/unit/hooks/useExpenseActionsInvoiceDeleteTest.tsx @@ -81,7 +81,7 @@ describe('useExpenseActions - invoice delete on the /e/:reportID (expense report // Regression test for issue #97399. Opening an invoice from the invoice room lands on `/e/:reportID` // (SearchMoneyRequestReportPage). Its header "More → Delete" runs useExpenseActions' DELETE action, which calls // getNavigationUrlOnMoneyRequestDelete to build the navigate-back URL and stores it in - // NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL. Invoice reports are excluded by useGetIOUReportFromReportAction, so + // RAM_ONLY_PENDING_TRANSACTION_DELETE_URL. Invoice reports are excluded by useGetIOUReportFromReportAction, so // before the fallback iouReport was undefined, the URL was undefined, no navigation happened, and the RHP was // left on "Not here". With the fallback the URL must resolve to the invoice room. it('writes the invoice-room back URL when deleting the only expense of an invoice', async () => { @@ -150,7 +150,7 @@ describe('useExpenseActions - invoice delete on the /e/:reportID (expense report }); await waitForBatchedUpdatesWithAct(); - const backUrl = await getOnyxValue(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const backUrl = await getOnyxValue(ONYXKEYS.RAM_ONLY_PENDING_TRANSACTION_DELETE_URL); expect(backUrl).toBe(ROUTES.REPORT_WITH_ID.getRoute(invoiceRoomID)); });