Skip to content
Open
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
38 changes: 32 additions & 6 deletions src/libs/actions/IOU/SendInvoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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,
Comment on lines +280 to +282

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve invoice readiness across app restarts

If the app or browser restarts after this offline write—particularly before the invalid SendInvoice request is rejected—this value is discarded because src/setup/index.ts registers this collection as RAM-only, and prepareRequest() strips optimisticData from the persisted request. The report, actions, and optimistic metadata remain persisted, so after the queued request fails, ReportFetchHandler still skips openReport() for the optimistic invoice room and re-arms isLoadingInitialReportActions, producing the same infinite skeleton. Reconstruct readiness from the persisted optimistic report/actions on startup, or otherwise make this state survive a restart, rather than relying solely on this RAM-only merge.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The creation-time stamp lives in a RAM-only key, so a restart between the offline create and reconnect would drop it and the skeleton would come back.

Fixed by also settling the state on the read side: when ReportFetchHandler intentionally skips openReport for an optimistic chat report, it now reconstructs the readiness stamp (markLocalReportActionsAsLoaded) from the persisted optimistic data, so it no longer depends on the RAM-only value surviving a restart. The creation-time stamp stays for parity with the expense flow and to cover the first session immediately. Added a unit test for the reconstruction path.

},
},
});
);
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
Expand Down Expand Up @@ -8438,6 +8450,7 @@ export {
updateChatName,
updateLastVisitTime,
updateLoadingInitialReportAction,
markLocalReportActionsAsLoaded,
updateNotificationPreference,
updatePolicyRoomName,
updatePrivateNotes,
Expand Down
9 changes: 9 additions & 0 deletions src/pages/inbox/ReportFetchHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
clearStaleDMRecoveryTargetByTargetReportID,
createTransactionThreadReport,
joinReportViaSecureLink,
markLocalReportActionsAsLoaded,
openReport,
readNewestAction,
setViewingPublicRoomReportID,
Expand Down Expand Up @@ -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;
}

Expand Down
28 changes: 28 additions & 0 deletions tests/actions/IOUTest/SendInvoiceTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Transaction>,
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;
Expand Down
16 changes: 16 additions & 0 deletions tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading