From a20015822306b23b86d5fded1e084996add7bb0f Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:25 +0500 Subject: [PATCH 1/2] fix: stamp invoice reports as loaded to stop infinite skeleton (#96925) Offline-created invoices never wrote RAM_ONLY_REPORT_LOADING_STATE, so on reconnect the invoice report and its optimistic invoice room stayed pinned at isLoadingInitialReportActions and showed an infinite loading skeleton. Mirror the expense flow in MoneyRequestBuilder by stamping hasOnceLoadedReportActions: true for both reports in optimisticData. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libs/actions/IOU/SendInvoice.ts | 38 ++++++++++++++++++++---- tests/actions/IOUTest/SendInvoiceTest.ts | 28 +++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) 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/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; From b991b4134dc227eaf5511f1b2bae8ec278225902 Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:39:35 +0500 Subject: [PATCH 2/2] fix: reconstruct optimistic-report readiness on open so it survives restarts The creation-time hasOnceLoadedReportActions stamp lives in a RAM-only key, so an app restart between the offline create and reconnect drops it, and the queued SendInvoice failure would leave the invoice room pinned on the loading skeleton again. When ReportFetchHandler intentionally skips openReport for an optimistic chat report, settle the loading state from the persisted optimistic data instead of relying solely on the RAM-only stamp. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libs/actions/Report/index.ts | 13 +++++++++++++ src/pages/inbox/ReportFetchHandler.tsx | 9 +++++++++ tests/actions/ReportTest.ts | 16 ++++++++++++++++ 3 files changed, 38 insertions(+) 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/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();