diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 45193a958859..bbff7085eb6b 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -59,6 +59,7 @@ type SendInvoiceInformation = { onyxData: OnyxData< | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_METADATA + | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES @@ -125,6 +126,7 @@ function buildOnyxDataForInvoice( ): OnyxData< | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_METADATA + | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES @@ -142,6 +144,7 @@ function buildOnyxDataForInvoice( OnyxUpdate< | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_METADATA + | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES @@ -171,6 +174,17 @@ function buildOnyxDataForInvoice( isOptimisticReport: true, }, }, + { + // The optimistic data below is the complete local truth for this offline-created invoice report, so its + // initial actions are already "loaded". Without this stamp the report stays pinned at + // isLoadingInitialReportActions on reconnect and shows an infinite skeleton (see #96925). Mirrors the + // expense flow in MoneyRequestBuilder. + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${iou.report?.reportID}`, + value: { + hasOnceLoadedReportActions: true, + }, + }, { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionParams.transaction.transactionID}`, @@ -250,13 +264,25 @@ function buildOnyxDataForInvoice( }); if (chat.isNewReport) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${chat.report?.reportID}`, - value: { - isOptimisticReport: true, + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${chat.report?.reportID}`, + value: { + isOptimisticReport: true, + }, + }, + { + // The invoice room is an optimistic CHAT, so ReportFetchHandler never fires openReport for it and + // nothing else would ever clear its initial-load state. Stamp it as loaded so it doesn't hang on an + // infinite skeleton once online (see #96925). + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${chat.report?.reportID}`, + value: { + hasOnceLoadedReportActions: true, + }, }, - }); + ); } } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index e00b906e3443..a6f9325478ce 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5800,6 +5800,18 @@ function updateLoadingInitialReportAction(reportID: string | undefined, isLoadin Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, {isLoadingInitialReportActions}); } +/** + * Marks a report's initial actions as loaded without a server round-trip. Used for optimistic reports whose local + * actions are the complete truth (openReport is intentionally never called for them), so the RAM-only loading state + * can be reconstructed after an app restart drops the stamp written at creation (see #96925). + */ +function markLocalReportActionsAsLoaded(reportID: string | undefined) { + if (!isValidReportIDFromPath(reportID)) { + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); +} + function setNewRoomFormLoading(isLoading = true) { Onyx.merge(`${ONYXKEYS.FORMS.NEW_ROOM_FORM}`, {isLoading}); } @@ -8438,6 +8450,7 @@ export { updateChatName, updateLastVisitTime, updateLoadingInitialReportAction, + markLocalReportActionsAsLoaded, updateNotificationPreference, updatePolicyRoomName, updatePrivateNotes, diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 1c0763cb29f1..dd6017d3b3f6 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -38,6 +38,7 @@ import { clearStaleDMRecoveryTargetByTargetReportID, createTransactionThreadReport, joinReportViaSecureLink, + markLocalReportActionsAsLoaded, openReport, readNewestAction, setViewingPublicRoomReportID, @@ -155,6 +156,14 @@ function ReportFetchHandler() { const fetchReport = useEffectEvent(() => { if (reportMetadata.isOptimisticReport && report?.type === CONST.REPORT.TYPE.CHAT && !isPolicyExpenseChat(report)) { + // openReport is intentionally never called for an optimistic chat report, so nothing else can settle its + // initial-load state. The stamp written at creation lives in a RAM-only key and is lost on an app restart, + // which would leave the report pinned on the loading skeleton once online (the effect below re-arms + // isLoadingInitialReportActions while hasOnceLoadedReportActions is false). The persisted local actions are + // the complete truth for an optimistic report, so reconstruct the readiness stamp here. See #96925. + if (!reportLoadingState.hasOnceLoadedReportActions) { + markLocalReportActionsAsLoaded(reportIDFromRoute); + } return; } diff --git a/tests/actions/IOUTest/SendInvoiceTest.ts b/tests/actions/IOUTest/SendInvoiceTest.ts index 0067cd5cd951..cfc538de3ec7 100644 --- a/tests/actions/IOUTest/SendInvoiceTest.ts +++ b/tests/actions/IOUTest/SendInvoiceTest.ts @@ -288,6 +288,34 @@ describe('actions/SendInvoice', () => { expect(result.onyxData.failureData).toBeDefined(); }); + it('stamps hasOnceLoadedReportActions for the invoice report and the new invoice room (#96925)', () => { + // Given: a brand new invoice (no existing chat report), which creates both an invoice report and an invoice room + const result = getSendInvoiceInformation({ + transaction: baseTransaction as OnyxEntry, + currentUserAccountID: 123, + policyRecentlyUsedCurrencies: [], + invoiceChatReport: undefined, + receiptFile: undefined, + policy: undefined, + policyTagList: undefined, + policyCategories: undefined, + companyName: undefined, + companyWebsite: undefined, + policyRecentlyUsedCategories: [], + senderPolicyTags: baseSenderPolicyTags, + formatPhoneNumber, + delegateAccountID: undefined, + }); + + // Then: both reports are stamped as "actions already loaded" so they never hang on an infinite skeleton once online + const optimisticData = result.onyxData.optimisticData ?? []; + const invoiceReportLoadingState = optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${result.invoiceReportID}`); + const invoiceRoomLoadingState = optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${result.invoiceRoom.reportID}`); + + expect(invoiceReportLoadingState?.value).toMatchObject({hasOnceLoadedReportActions: true}); + expect(invoiceRoomLoadingState?.value).toMatchObject({hasOnceLoadedReportActions: true}); + }); + describe('delegateAccountID forwarding', () => { it('sets delegateAccountID on the IOU action when delegateAccountID is provided', () => { const DELEGATE_ACCOUNT_ID = 999; diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 85d69195385f..eed104983b4e 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1276,6 +1276,22 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); }); + it('markLocalReportActionsAsLoaded settles the initial-load state for an optimistic report (#96925)', async () => { + const REPORT_ID = '96925001'; + + // Given the RAM-only loading state was dropped by an app restart, leaving the report re-armed as loading + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isLoadingInitialReportActions: true, hasOnceLoadedReportActions: false}); + await waitForBatchedUpdates(); + + // When the readiness stamp is reconstructed on open (ReportFetchHandler's optimistic-report skip path) + Report.markLocalReportActionsAsLoaded(REPORT_ID); + await waitForBatchedUpdates(); + + // Then both flags settle so the report can never hang on the loading skeleton + const loadingState = await getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`); + expect(loadingState).toMatchObject({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); + }); + it('openReport legacy preview fallback stores action under correct Onyx key and preserves existing actions', async () => { global.fetch = TestHelper.createGlobalFetchMock();