From 7ab394cb0c582152f1fb8736161d2362174b0539 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 13 Jan 2026 19:03:53 +0700 Subject: [PATCH 01/10] Remove Onyx.connect for the key REPORT_DRAFT_COMMENT --- src/libs/ReportUtils.ts | 9 +- src/libs/actions/Report.ts | 10 +- tests/actions/ReportTest.ts | 182 ++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 10 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1baa10648aa3..659d58d070f4 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1058,6 +1058,13 @@ Onyx.connectWithoutView({ callback: (value) => (allPolicyDrafts = value), }); +let allReportDraftComments: Record = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + waitForCollectionCallback: true, + callback: (value) => (allReportDraftComments = value ?? {}), +}); + let allReports: OnyxCollection; let reportsByPolicyID: ReportByPolicyMap; Onyx.connectWithoutView({ @@ -1075,7 +1082,7 @@ Onyx.connectWithoutView({ return acc; } - handlePreexistingReport(report); + handlePreexistingReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]); // Get all reports, which are the ones that are: // - Owned by the same user diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 469cf74c80da..f4f0c5df1a33 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -354,13 +354,6 @@ Onyx.connect({ callback: (val) => (introSelected = val), }); -let allReportDraftComments: Record = {}; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - waitForCollectionCallback: true, - callback: (value) => (allReportDraftComments = value), -}); - let environment: EnvironmentType; getEnvironment().then((env) => { environment = env; @@ -1938,7 +1931,7 @@ function broadcastUserIsLeavingRoom(reportID: string) { Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); } -function handlePreexistingReport(report: Report) { +function handlePreexistingReport(report: Report, draftReportComment: string | undefined) { const {reportID, preexistingReportID, parentReportID, parentReportActionID} = report; if (!reportID || !preexistingReportID) { @@ -1992,7 +1985,6 @@ function handlePreexistingReport(report: Report) { // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report // after that clear the optimistically created report - const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; if (!draftReportComment) { callback(); return; diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 7f5435ea39b8..0f2b42d151a2 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3235,4 +3235,186 @@ describe('actions/Report', () => { expect(reportsCollectionAfter).toBeUndefined(); }); }); + + describe('handlePreexistingReport', () => { + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should do nothing if reportID is missing', async () => { + const report = createRandomReport(1, undefined); + report.reportID = ''; + report.preexistingReportID = '2'; + + Report.handlePreexistingReport(report, undefined); + + await waitForBatchedUpdates(); + + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); + expect(updatedReport).toBeUndefined(); + }); + + it('should do nothing if preexistingReportID is missing', async () => { + const report = createRandomReport(1, undefined); + report.reportID = '1'; + report.preexistingReportID = undefined; + + Report.handlePreexistingReport(report, undefined); + + await waitForBatchedUpdates(); + + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); + expect(updatedReport).toBeUndefined(); + }); + + it('should clean up stale optimistic IOU report and its report preview', async () => { + const parentReportID = '10'; + const parentReportActionID = 'action123'; + const reportID = '1'; + const preexistingReportID = '2'; + + // Create an IOU report with parent info + const report = createRandomReport(1, undefined); + report.reportID = reportID; + report.preexistingReportID = preexistingReportID; + report.parentReportID = parentReportID; + report.parentReportActionID = parentReportActionID; + report.type = CONST.REPORT.TYPE.IOU; + + const parentAction = createRandomReportAction(1); + + // Set up parent report with action + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: parentAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + Report.handlePreexistingReport(report, undefined); + + await waitForBatchedUpdates(); + + // Verify the parent report action is set to null (deleted) + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); + expect(parentActions?.[parentReportActionID]).toBeFalsy(); + + // Verify the optimistic report is set to null (deleted) - Onyx returns undefined for deleted keys + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should merge optimistic DM report into preexisting report without draft comment', async () => { + const reportID = '1'; + const preexistingReportID = '2'; + + // Create optimistic and preexisting reports + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.reportName = 'Optimistic Report'; + optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.reportName = 'Existing Report'; + existingReport.participants = {2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + Report.handlePreexistingReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Allow time for InteractionManager callback + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + await waitForBatchedUpdates(); + + // Verify optimistic report is deleted - Onyx returns undefined for deleted keys + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + + // Verify preexisting report is updated with optimistic data but keeps existing participants + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport).toBeDefined(); + expect(updatedReport?.reportID).toBe(preexistingReportID); + expect(updatedReport?.preexistingReportID).toBeFalsy(); + expect(updatedReport?.participants).toEqual(existingReport.participants); + }); + + it('should transfer draft comment from optimistic report to preexisting report', async () => { + const reportID = '1'; + const preexistingReportID = '2'; + const draftComment = 'This is a draft comment'; + + // Create optimistic and preexisting reports + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, draftComment); + await waitForBatchedUpdates(); + + Report.handlePreexistingReport(optimisticReport, draftComment); + + await waitForBatchedUpdates(); + + // Allow time for InteractionManager callback + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + await waitForBatchedUpdates(); + + // Verify draft comment is transferred to preexisting report + const transferredDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${preexistingReportID}`); + expect(transferredDraft).toBe(draftComment); + + // Verify optimistic report's draft is cleared - Onyx returns undefined for deleted keys + const clearedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + expect(clearedDraft).toBeFalsy(); + }); + + it('should handle preexisting report when participants is undefined in existing report', async () => { + const reportID = '1'; + const preexistingReportID = '2'; + + // Create optimistic report with participants + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + // Create existing report without participants + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.participants = undefined; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + Report.handlePreexistingReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Allow time for InteractionManager callback + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + await waitForBatchedUpdates(); + + // Verify preexisting report uses optimistic report's participants when existing has none + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport?.participants).toEqual(optimisticReport.participants); + }); + }); }); From 0ea695fc17e02f90574eb165bb20c2deb13fe05e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 21 Jan 2026 16:46:08 +0700 Subject: [PATCH 02/10] move handlePreexistingReport to the separate files --- src/Expensify.tsx | 2 ++ src/libs/PreexistingReportHandler/index.ts | 32 ++++++++++++++++++++++ src/libs/ReportUtils.ts | 11 +------- 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/libs/PreexistingReportHandler/index.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 4c995bab6dbc..84893144a3f3 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -41,6 +41,8 @@ import NetworkConnection from './libs/NetworkConnection'; import PushNotification from './libs/Notification/PushNotification'; import './libs/Notification/PushNotification/subscribeToPushNotifications'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection +import './libs/PreexistingReportHandler'; +// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import './libs/registerPaginationConfig'; import setCrashlyticsUserId from './libs/setCrashlyticsUserId'; import StartupTimer from './libs/StartupTimer'; diff --git a/src/libs/PreexistingReportHandler/index.ts b/src/libs/PreexistingReportHandler/index.ts new file mode 100644 index 000000000000..d27dae70aa3a --- /dev/null +++ b/src/libs/PreexistingReportHandler/index.ts @@ -0,0 +1,32 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import {handlePreexistingReport} from '@libs/actions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +let allReportDraftComments: Record = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + waitForCollectionCallback: true, + callback: (value) => (allReportDraftComments = value ?? {}), +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value: OnyxCollection) => { + if (!value) { + return; + } + + for (const report of Object.values(value)) { + if (!report) { + continue; + } + + handlePreexistingReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]); + } + }, +}); + +export default {}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 659d58d070f4..01a805093e76 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -101,7 +101,7 @@ import type {IOURequestType} from './actions/IOU'; import {isApprover as isApproverUtils} from './actions/Policy/Member'; import {createDraftWorkspace} from './actions/Policy/Policy'; import {hasCreditBankAccount} from './actions/ReimbursementAccount/store'; -import {handlePreexistingReport, openUnreportedExpense} from './actions/Report'; +import {openUnreportedExpense} from './actions/Report'; import type {GuidedSetupData, TaskForParameters} from './actions/Report'; import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; import {removeDraftTransactions} from './actions/TransactionEdit'; @@ -1058,13 +1058,6 @@ Onyx.connectWithoutView({ callback: (value) => (allPolicyDrafts = value), }); -let allReportDraftComments: Record = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - waitForCollectionCallback: true, - callback: (value) => (allReportDraftComments = value ?? {}), -}); - let allReports: OnyxCollection; let reportsByPolicyID: ReportByPolicyMap; Onyx.connectWithoutView({ @@ -1082,8 +1075,6 @@ Onyx.connectWithoutView({ return acc; } - handlePreexistingReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]); - // Get all reports, which are the ones that are: // - Owned by the same user // - Are either open or submitted From 8a99a570f88a71fc23acd18e3854cceba3458d4c Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 22 Jan 2026 11:40:47 +0700 Subject: [PATCH 03/10] fix test --- tests/unit/MiddlewareTest.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index f535f43fb412..e60dd11c27f1 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -6,6 +6,8 @@ import handleUnusedOptimisticID from '@src/libs/Middleware/HandleUnusedOptimisti import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; +// This import is needed to initialize the Onyx connections that call handlePreexistingReport +import '@src/libs/PreexistingReportHandler'; import * as Request from '@src/libs/Request'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report as OnyxReport, PersonalDetailsList} from '@src/types/onyx'; @@ -24,6 +26,8 @@ beforeAll(() => { }); beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); SequentialQueue.pause(); MainQueue.clear(); HttpUtils.cancelPendingRequests(); From ea035db5f902d7486662094c6195dd8339003ec9 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 22 Jan 2026 15:25:24 +0700 Subject: [PATCH 04/10] update test --- tests/actions/ReportTest.ts | 51 ++----------------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index cadfb44942fc..e5427295db03 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -23,6 +23,7 @@ import * as User from '@src/libs/actions/User'; import DateUtils from '@src/libs/DateUtils'; import Log from '@src/libs/Log'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; +import '@src/libs/PreexistingReportHandler'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -3215,6 +3216,7 @@ describe('actions/Report', () => { report.type = CONST.REPORT.TYPE.IOU; const parentAction = createRandomReportAction(1); + parentAction.childReportID = reportID; // Set up parent report with action await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { @@ -3260,12 +3262,6 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); - // Allow time for InteractionManager callback - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - await waitForBatchedUpdates(); - // Verify optimistic report is deleted - Onyx returns undefined for deleted keys const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); expect(deletedReport).toBeFalsy(); @@ -3278,43 +3274,6 @@ describe('actions/Report', () => { expect(updatedReport?.participants).toEqual(existingReport.participants); }); - it('should transfer draft comment from optimistic report to preexisting report', async () => { - const reportID = '1'; - const preexistingReportID = '2'; - const draftComment = 'This is a draft comment'; - - // Create optimistic and preexisting reports - const optimisticReport = createRandomReport(Number(reportID), undefined); - optimisticReport.reportID = reportID; - optimisticReport.preexistingReportID = preexistingReportID; - - const existingReport = createRandomReport(Number(preexistingReportID), undefined); - existingReport.reportID = preexistingReportID; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, draftComment); - await waitForBatchedUpdates(); - - Report.handlePreexistingReport(optimisticReport, draftComment); - - await waitForBatchedUpdates(); - - // Allow time for InteractionManager callback - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - await waitForBatchedUpdates(); - - // Verify draft comment is transferred to preexisting report - const transferredDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${preexistingReportID}`); - expect(transferredDraft).toBe(draftComment); - - // Verify optimistic report's draft is cleared - Onyx returns undefined for deleted keys - const clearedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - expect(clearedDraft).toBeFalsy(); - }); - it('should handle preexisting report when participants is undefined in existing report', async () => { const reportID = '1'; const preexistingReportID = '2'; @@ -3338,12 +3297,6 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); - // Allow time for InteractionManager callback - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - await waitForBatchedUpdates(); - // Verify preexisting report uses optimistic report's participants when existing has none const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); expect(updatedReport?.participants).toEqual(optimisticReport.participants); From d30d1ea2c14f206d21e431d7dbf8ba82e4e43f3b Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Jan 2026 15:34:48 +0700 Subject: [PATCH 05/10] rename + add comments --- src/libs/PreexistingReportHandler/index.ts | 143 ++++++++++++++++++++- src/libs/actions/Report.ts | 93 -------------- tests/actions/ReportTest.ts | 14 +- tests/unit/MiddlewareTest.ts | 2 +- 4 files changed, 148 insertions(+), 104 deletions(-) diff --git a/src/libs/PreexistingReportHandler/index.ts b/src/libs/PreexistingReportHandler/index.ts index d27dae70aa3a..10de1c418d2a 100644 --- a/src/libs/PreexistingReportHandler/index.ts +++ b/src/libs/PreexistingReportHandler/index.ts @@ -1,8 +1,35 @@ +import {DeviceEventEmitter, InteractionManager} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {handlePreexistingReport} from '@libs/actions/Report'; +import {saveReportDraftComment} from '@libs/actions/Report'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {isMoneyRequest, isMoneyRequestReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Report, ReportActions} from '@src/types/onyx'; + +/** + * PreexistingReportHandler + * + * This module handles a specific edge case in the Expensify app's offline-first architecture. + * + * THE PROBLEM: + * When a user creates a new DM or group chat, we optimistically create a report with a temporary + * reportID so they can start using it immediately (offline-first UX). However, when the API request + * completes, the server might respond that a report already exists for that set of participants + * (e.g., if the user previously had a DM with that person). In this case, the API returns a + * `preexistingReportID` indicating which report should be used instead of the optimistic one. + * + * THE SOLUTION: + * This module listens to the REPORT collection in Onyx. When a report comes in with a + * `preexistingReportID` field set, it means we need to: + * 1. Delete the optimistically created report (the one with the temporary ID) + * 2. Redirect the user to the preexisting report (if they're currently viewing the optimistic one) + * 3. Transfer any draft comment from the optimistic report to the preexisting report + * 4. Clean up associated data like parent report actions for money request reports + * + */ let allReportDraftComments: Record = {}; Onyx.connectWithoutView({ @@ -11,10 +38,118 @@ Onyx.connectWithoutView({ callback: (value) => (allReportDraftComments = value ?? {}), }); +let allReports: OnyxCollection; + +const allReportActions: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + callback: (actions, key) => { + if (!key || !actions) { + return; + } + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + allReportActions[reportID] = actions; + }, +}); + +function replaceOptimisticReportWithActualReport(report: Report, draftReportComment: string | undefined) { + const {reportID, preexistingReportID, parentReportID, parentReportActionID} = report; + + if (!reportID || !preexistingReportID) { + return; + } + + // Handle cleanup of stale optimistic IOU report and its report preview separately + if ((isMoneyRequestReport(report) || isMoneyRequest(report)) && parentReportID && parentReportActionID) { + const parentReportAction = allReportActions?.[parentReportID]?.[parentReportActionID]; + if (parentReportAction?.childReportID === reportID) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: null, + }); + } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. + // In this case, the API will let us know by returning a preexistingReportID. + // We should clear out the optimistically created report and re-route the user to the preexisting report. + let callback = () => { + const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; + + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + // Replacing the existing report's participants to avoid duplicates + participants: existingReport?.participants ?? report.participants, + }); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); + }; + + if (!navigationRef.isReady()) { + callback(); + return; + } + + // Use Navigation.getActiveRoute() instead of navigationRef.getCurrentRoute()?.path because + // getCurrentRoute().path can be undefined during first navigation. + // We still need getCurrentRoute() for params and screen name as getActiveRoute() only returns the path string. + const activeRoute = Navigation.getActiveRoute(); + const currentRouteInfo = navigationRef.getCurrentRoute(); + const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; + const screenName = currentRouteInfo?.name; + + const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`); + + // Fix specific case: https://github.com/Expensify/App/pull/77657#issuecomment-3678696730. + // When user is editing a money request report (/e/:reportID route) and has + // an optimistic report in the background that should be replaced with preexisting report + const isOptimisticReportInBackground = screenName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT && backTo && backTo.includes(`/r/${reportID}`); + + // Only re-route them if they are still looking at the optimistically created report + if (isOptimisticReportFocused || isOptimisticReportInBackground) { + const currCallback = callback; + callback = () => { + currCallback(); + if (isOptimisticReportFocused) { + Navigation.setParams({reportID: preexistingReportID.toString()}); + } else if (isOptimisticReportInBackground) { + // Navigate to the correct backTo route with the preexisting report ID + Navigation.navigate(backTo.replace(`/r/${reportID}`, `/r/${preexistingReportID}`) as Route); + } + }; + + // The report screen will listen to this event and transfer the draft comment to the existing report + // This will allow the newest draft comment to be transferred to the existing report + DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { + preexistingReportID, + callback, + }); + + return; + } + + // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report + // after that clear the optimistically created report + if (!draftReportComment) { + callback(); + return; + } + + saveReportDraftComment(preexistingReportID, draftReportComment, callback); + }); +} + Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value: OnyxCollection) => { + allReports = value; + if (!value) { return; } @@ -24,9 +159,11 @@ Onyx.connectWithoutView({ continue; } - handlePreexistingReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]); + replaceOptimisticReportWithActualReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]); } }, }); +export {replaceOptimisticReportWithActualReport}; + export default {}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6bea5ae5cbd0..3105f51c1f60 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1879,98 +1879,6 @@ function broadcastUserIsLeavingRoom(reportID: string, currentUserAccountID: numb Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); } -function handlePreexistingReport(report: Report, draftReportComment: string | undefined) { - const {reportID, preexistingReportID, parentReportID, parentReportActionID} = report; - - if (!reportID || !preexistingReportID) { - return; - } - - // Handle cleanup of stale optimistic IOU report and its report preview separately - if ((isMoneyRequestReport(report) || isMoneyRequest(report)) && parentReportID && parentReportActionID) { - const parentReportAction = allReportActions?.[parentReportID]?.[parentReportActionID]; - if (parentReportAction?.childReportID === reportID) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - [parentReportActionID]: null, - }); - } - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - return; - } - - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. - // In this case, the API will let us know by returning a preexistingReportID. - // We should clear out the optimistically created report and re-route the user to the preexisting report. - let callback = () => { - const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; - - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { - ...report, - reportID: preexistingReportID, - preexistingReportID: null, - // Replacing the existing report's participants to avoid duplicates - participants: existingReport?.participants ?? report.participants, - }); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); - }; - - if (!navigationRef.isReady()) { - callback(); - return; - } - - // Use Navigation.getActiveRoute() instead of navigationRef.getCurrentRoute()?.path because - // getCurrentRoute().path can be undefined during first navigation. - // We still need getCurrentRoute() for params and screen name as getActiveRoute() only returns the path string. - const activeRoute = Navigation.getActiveRoute(); - const currentRouteInfo = navigationRef.getCurrentRoute(); - const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; - const screenName = currentRouteInfo?.name; - - const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`); - - // Fix specific case: https://github.com/Expensify/App/pull/77657#issuecomment-3678696730. - // When user is editing a money request report (/e/:reportID route) and has - // an optimistic report in the background that should be replaced with preexisting report - const isOptimisticReportInBackground = screenName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT && backTo && backTo.includes(`/r/${reportID}`); - - // Only re-route them if they are still looking at the optimistically created report - if (isOptimisticReportFocused || isOptimisticReportInBackground) { - const currCallback = callback; - callback = () => { - currCallback(); - if (isOptimisticReportFocused) { - Navigation.setParams({reportID: preexistingReportID.toString()}); - } else if (isOptimisticReportInBackground) { - // Navigate to the correct backTo route with the preexisting report ID - Navigation.navigate(backTo.replace(`/r/${reportID}`, `/r/${preexistingReportID}`) as Route); - } - }; - - // The report screen will listen to this event and transfer the draft comment to the existing report - // This will allow the newest draft comment to be transferred to the existing report - DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { - preexistingReportID, - callback, - }); - - return; - } - - // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report - // after that clear the optimistically created report - if (!draftReportComment) { - callback(); - return; - } - - saveReportDraftComment(preexistingReportID, draftReportComment, callback); - }); -} - /** Deletes a comment from the report, basically sets it as empty string */ function deleteReportComment( reportID: string | undefined, @@ -6529,7 +6437,6 @@ export { getNewerActions, getOlderActions, getReportPrivateNote, - handlePreexistingReport, handleUserDeletedLinksInHtml, hasErrorInPrivateNotes, inviteToGroupChat, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index e5427295db03..a6ccf0b74ff3 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -23,7 +23,7 @@ import * as User from '@src/libs/actions/User'; import DateUtils from '@src/libs/DateUtils'; import Log from '@src/libs/Log'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; -import '@src/libs/PreexistingReportHandler'; +import {replaceOptimisticReportWithActualReport} from '@src/libs/PreexistingReportHandler'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -3169,7 +3169,7 @@ describe('actions/Report', () => { }); }); - describe('handlePreexistingReport', () => { + describe('replaceOptimisticReportWithActualReport', () => { beforeEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); @@ -3180,7 +3180,7 @@ describe('actions/Report', () => { report.reportID = ''; report.preexistingReportID = '2'; - Report.handlePreexistingReport(report, undefined); + replaceOptimisticReportWithActualReport(report, undefined); await waitForBatchedUpdates(); @@ -3193,7 +3193,7 @@ describe('actions/Report', () => { report.reportID = '1'; report.preexistingReportID = undefined; - Report.handlePreexistingReport(report, undefined); + replaceOptimisticReportWithActualReport(report, undefined); await waitForBatchedUpdates(); @@ -3225,7 +3225,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await waitForBatchedUpdates(); - Report.handlePreexistingReport(report, undefined); + replaceOptimisticReportWithActualReport(report, undefined); await waitForBatchedUpdates(); @@ -3258,7 +3258,7 @@ describe('actions/Report', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); await waitForBatchedUpdates(); - Report.handlePreexistingReport(optimisticReport, undefined); + replaceOptimisticReportWithActualReport(optimisticReport, undefined); await waitForBatchedUpdates(); @@ -3293,7 +3293,7 @@ describe('actions/Report', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); await waitForBatchedUpdates(); - Report.handlePreexistingReport(optimisticReport, undefined); + replaceOptimisticReportWithActualReport(optimisticReport, undefined); await waitForBatchedUpdates(); diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index e60dd11c27f1..2b31ed421797 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -6,7 +6,7 @@ import handleUnusedOptimisticID from '@src/libs/Middleware/HandleUnusedOptimisti import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; -// This import is needed to initialize the Onyx connections that call handlePreexistingReport +// This import is needed to initialize the Onyx connections that call replaceOptimisticReportWithActualReport import '@src/libs/PreexistingReportHandler'; import * as Request from '@src/libs/Request'; import ONYXKEYS from '@src/ONYXKEYS'; From f3b7d6c7939e6ed34c462aceb07fd37c92d6d244 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Jan 2026 16:39:38 +0700 Subject: [PATCH 06/10] move files --- src/Expensify.tsx | 4 +- ...eplaceOptimisticReportWithActualReport.ts} | 2 +- ...aceOptimisticReportWithActualReportTest.ts | 162 ++++++++++++++++++ tests/actions/ReportTest.ts | 135 --------------- tests/unit/MiddlewareTest.ts | 4 +- 5 files changed, 167 insertions(+), 140 deletions(-) rename src/libs/{PreexistingReportHandler/index.ts => actions/replaceOptimisticReportWithActualReport.ts} (99%) create mode 100644 tests/actions/ReplaceOptimisticReportWithActualReportTest.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index ff45a559a122..96ccd68abb09 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -26,6 +26,8 @@ import {updateLastRoute} from './libs/actions/App'; import {disconnect} from './libs/actions/Delegate'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import {openReportFromDeepLink} from './libs/actions/Link'; +// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection +import './libs/actions/replaceOptimisticReportWithActualReport'; import * as Report from './libs/actions/Report'; import {hasAuthToken} from './libs/actions/Session'; import * as User from './libs/actions/User'; @@ -42,8 +44,6 @@ import NetworkConnection from './libs/NetworkConnection'; import PushNotification from './libs/Notification/PushNotification'; import './libs/Notification/PushNotification/subscribeToPushNotifications'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection -import './libs/PreexistingReportHandler'; -// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import './libs/registerPaginationConfig'; import setCrashlyticsUserId from './libs/setCrashlyticsUserId'; import StartupTimer from './libs/StartupTimer'; diff --git a/src/libs/PreexistingReportHandler/index.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts similarity index 99% rename from src/libs/PreexistingReportHandler/index.ts rename to src/libs/actions/replaceOptimisticReportWithActualReport.ts index 10de1c418d2a..9feeb4b82ed1 100644 --- a/src/libs/PreexistingReportHandler/index.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -10,7 +10,7 @@ import SCREENS from '@src/SCREENS'; import type {Report, ReportActions} from '@src/types/onyx'; /** - * PreexistingReportHandler + * replaceOptimisticReportWithActualReport * * This module handles a specific edge case in the Expensify app's offline-first architecture. * diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts new file mode 100644 index 000000000000..276e169342bb --- /dev/null +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -0,0 +1,162 @@ +import {beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import createRandomReportAction from '../utils/collections/reportActions'; +import {createRandomReport} from '../utils/collections/reports'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + setParams: jest.fn(), + getActiveRoute: jest.fn(() => ''), + navigationRef: { + isReady: jest.fn(() => false), + getCurrentRoute: jest.fn(), + }, +})); + +// Import after mocking Navigation +// eslint-disable-next-line import/first +import {replaceOptimisticReportWithActualReport} from '@src/libs/actions/replaceOptimisticReportWithActualReport'; + +describe('replaceOptimisticReportWithActualReport', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should do nothing if reportID is missing', async () => { + const report = createRandomReport(1, undefined); + report.reportID = ''; + report.preexistingReportID = '2'; + + replaceOptimisticReportWithActualReport(report, undefined); + + await waitForBatchedUpdates(); + + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); + expect(updatedReport).toBeUndefined(); + }); + + it('should do nothing if preexistingReportID is missing', async () => { + const report = createRandomReport(1, undefined); + report.reportID = '1'; + report.preexistingReportID = undefined; + + replaceOptimisticReportWithActualReport(report, undefined); + + await waitForBatchedUpdates(); + + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); + expect(updatedReport).toBeUndefined(); + }); + + it('should clean up stale optimistic IOU report and its report preview', async () => { + const parentReportID = '10'; + const parentReportActionID = 'action123'; + const reportID = '1'; + const preexistingReportID = '2'; + + // Create an IOU report with parent info + const report = createRandomReport(1, undefined); + report.reportID = reportID; + report.preexistingReportID = preexistingReportID; + report.parentReportID = parentReportID; + report.parentReportActionID = parentReportActionID; + report.type = CONST.REPORT.TYPE.IOU; + + const parentAction = createRandomReportAction(1); + parentAction.childReportID = reportID; + + // Set up parent report with action + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: parentAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + replaceOptimisticReportWithActualReport(report, undefined); + + await waitForBatchedUpdates(); + + // Verify the parent report action is set to null (deleted) + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); + expect(parentActions?.[parentReportActionID]).toBeFalsy(); + + // Verify the optimistic report is set to null (deleted) - Onyx returns undefined for deleted keys + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + }); + + it('should merge optimistic DM report into preexisting report without draft comment', async () => { + const reportID = '1'; + const preexistingReportID = '2'; + + // Create optimistic and preexisting reports + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.reportName = 'Optimistic Report'; + optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.reportName = 'Existing Report'; + existingReport.participants = {2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Verify optimistic report is deleted - Onyx returns undefined for deleted keys + const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + expect(deletedReport).toBeFalsy(); + + // Verify preexisting report is updated with optimistic data but keeps existing participants + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport).toBeDefined(); + expect(updatedReport?.reportID).toBe(preexistingReportID); + expect(updatedReport?.preexistingReportID).toBeFalsy(); + expect(updatedReport?.participants).toEqual(existingReport.participants); + }); + + it('should handle preexisting report when participants is undefined in existing report', async () => { + const reportID = '1'; + const preexistingReportID = '2'; + + // Create optimistic report with participants + const optimisticReport = createRandomReport(Number(reportID), undefined); + optimisticReport.reportID = reportID; + optimisticReport.preexistingReportID = preexistingReportID; + optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + + // Create existing report without participants + const existingReport = createRandomReport(Number(preexistingReportID), undefined); + existingReport.reportID = preexistingReportID; + existingReport.participants = undefined; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); + await waitForBatchedUpdates(); + + replaceOptimisticReportWithActualReport(optimisticReport, undefined); + + await waitForBatchedUpdates(); + + // Verify preexisting report uses optimistic report's participants when existing has none + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); + expect(updatedReport?.participants).toEqual(optimisticReport.participants); + }); +}); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index a6ccf0b74ff3..b44284d34b12 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -23,7 +23,6 @@ import * as User from '@src/libs/actions/User'; import DateUtils from '@src/libs/DateUtils'; import Log from '@src/libs/Log'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; -import {replaceOptimisticReportWithActualReport} from '@src/libs/PreexistingReportHandler'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -3168,138 +3167,4 @@ describe('actions/Report', () => { expect(reportsCollectionAfter).toBeUndefined(); }); }); - - describe('replaceOptimisticReportWithActualReport', () => { - beforeEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - it('should do nothing if reportID is missing', async () => { - const report = createRandomReport(1, undefined); - report.reportID = ''; - report.preexistingReportID = '2'; - - replaceOptimisticReportWithActualReport(report, undefined); - - await waitForBatchedUpdates(); - - const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); - expect(updatedReport).toBeUndefined(); - }); - - it('should do nothing if preexistingReportID is missing', async () => { - const report = createRandomReport(1, undefined); - report.reportID = '1'; - report.preexistingReportID = undefined; - - replaceOptimisticReportWithActualReport(report, undefined); - - await waitForBatchedUpdates(); - - const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}1`); - expect(updatedReport).toBeUndefined(); - }); - - it('should clean up stale optimistic IOU report and its report preview', async () => { - const parentReportID = '10'; - const parentReportActionID = 'action123'; - const reportID = '1'; - const preexistingReportID = '2'; - - // Create an IOU report with parent info - const report = createRandomReport(1, undefined); - report.reportID = reportID; - report.preexistingReportID = preexistingReportID; - report.parentReportID = parentReportID; - report.parentReportActionID = parentReportActionID; - report.type = CONST.REPORT.TYPE.IOU; - - const parentAction = createRandomReportAction(1); - parentAction.childReportID = reportID; - - // Set up parent report with action - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - [parentReportActionID]: parentAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await waitForBatchedUpdates(); - - replaceOptimisticReportWithActualReport(report, undefined); - - await waitForBatchedUpdates(); - - // Verify the parent report action is set to null (deleted) - const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); - expect(parentActions?.[parentReportActionID]).toBeFalsy(); - - // Verify the optimistic report is set to null (deleted) - Onyx returns undefined for deleted keys - const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - expect(deletedReport).toBeFalsy(); - }); - - it('should merge optimistic DM report into preexisting report without draft comment', async () => { - const reportID = '1'; - const preexistingReportID = '2'; - - // Create optimistic and preexisting reports - const optimisticReport = createRandomReport(Number(reportID), undefined); - optimisticReport.reportID = reportID; - optimisticReport.preexistingReportID = preexistingReportID; - optimisticReport.reportName = 'Optimistic Report'; - optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; - - const existingReport = createRandomReport(Number(preexistingReportID), undefined); - existingReport.reportID = preexistingReportID; - existingReport.reportName = 'Existing Report'; - existingReport.participants = {2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); - await waitForBatchedUpdates(); - - replaceOptimisticReportWithActualReport(optimisticReport, undefined); - - await waitForBatchedUpdates(); - - // Verify optimistic report is deleted - Onyx returns undefined for deleted keys - const deletedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - expect(deletedReport).toBeFalsy(); - - // Verify preexisting report is updated with optimistic data but keeps existing participants - const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); - expect(updatedReport).toBeDefined(); - expect(updatedReport?.reportID).toBe(preexistingReportID); - expect(updatedReport?.preexistingReportID).toBeFalsy(); - expect(updatedReport?.participants).toEqual(existingReport.participants); - }); - - it('should handle preexisting report when participants is undefined in existing report', async () => { - const reportID = '1'; - const preexistingReportID = '2'; - - // Create optimistic report with participants - const optimisticReport = createRandomReport(Number(reportID), undefined); - optimisticReport.reportID = reportID; - optimisticReport.preexistingReportID = preexistingReportID; - optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; - - // Create existing report without participants - const existingReport = createRandomReport(Number(preexistingReportID), undefined); - existingReport.reportID = preexistingReportID; - existingReport.participants = undefined; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); - await waitForBatchedUpdates(); - - replaceOptimisticReportWithActualReport(optimisticReport, undefined); - - await waitForBatchedUpdates(); - - // Verify preexisting report uses optimistic report's participants when existing has none - const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`); - expect(updatedReport?.participants).toEqual(optimisticReport.participants); - }); - }); }); diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index 2b31ed421797..2d449d11cfa3 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -1,13 +1,13 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import SaveResponseInOnyx from '@libs/Middleware/SaveResponseInOnyx'; +// This import is needed to initialize the Onyx connections that call replaceOptimisticReportWithActualReport +import '@src/libs/actions/replaceOptimisticReportWithActualReport'; import HttpUtils from '@src/libs/HttpUtils'; import handleUnusedOptimisticID from '@src/libs/Middleware/HandleUnusedOptimisticID'; import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; -// This import is needed to initialize the Onyx connections that call replaceOptimisticReportWithActualReport -import '@src/libs/PreexistingReportHandler'; import * as Request from '@src/libs/Request'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report as OnyxReport, PersonalDetailsList} from '@src/types/onyx'; From 2ff058a792f4feafded191fc5edd6f7340a2f159 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Jan 2026 17:07:30 +0700 Subject: [PATCH 07/10] lint fix --- src/libs/actions/Report.ts | 6 +----- .../ReplaceOptimisticReportWithActualReportTest.ts | 11 ++++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3105f51c1f60..15b9d77eef6a 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -78,7 +78,7 @@ import Log from '@libs/Log'; import {isEmailPublicDomain} from '@libs/LoginUtils'; import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as NetworkStore from '@libs/Network/NetworkStore'; import NetworkConnection from '@libs/NetworkConnection'; @@ -160,8 +160,6 @@ import { isGroupChat as isGroupChatReportUtils, isHiddenForCurrentUser, isIOUReportUsingReport, - isMoneyRequest, - isMoneyRequestReport, isOpenExpenseReport, isProcessingReport, isReportManuallyReimbursed, @@ -181,8 +179,6 @@ import type {OnboardingAccounting} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/NewRoomForm'; import type { BankAccountList, diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts index 276e169342bb..f3e221aa9517 100644 --- a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -1,6 +1,7 @@ import {beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; +import {replaceOptimisticReportWithActualReport} from '@src/libs/actions/replaceOptimisticReportWithActualReport'; import ONYXKEYS from '@src/ONYXKEYS'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; @@ -17,10 +18,6 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })); -// Import after mocking Navigation -// eslint-disable-next-line import/first -import {replaceOptimisticReportWithActualReport} from '@src/libs/actions/replaceOptimisticReportWithActualReport'; - describe('replaceOptimisticReportWithActualReport', () => { beforeAll(() => { Onyx.init({ @@ -105,12 +102,12 @@ describe('replaceOptimisticReportWithActualReport', () => { optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; optimisticReport.reportName = 'Optimistic Report'; - optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + optimisticReport.participants = {'1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; const existingReport = createRandomReport(Number(preexistingReportID), undefined); existingReport.reportID = preexistingReportID; existingReport.reportName = 'Existing Report'; - existingReport.participants = {2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + existingReport.participants = {'2': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); @@ -140,7 +137,7 @@ describe('replaceOptimisticReportWithActualReport', () => { const optimisticReport = createRandomReport(Number(reportID), undefined); optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; - optimisticReport.participants = {1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + optimisticReport.participants = {'1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; // Create existing report without participants const existingReport = createRandomReport(Number(preexistingReportID), undefined); From c5636ddb8ef993008677f3b8a72558ea578e5f1e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Jan 2026 17:28:44 +0700 Subject: [PATCH 08/10] lint fix --- .../ReplaceOptimisticReportWithActualReportTest.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts index f3e221aa9517..e51669ca771b 100644 --- a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -96,18 +96,20 @@ describe('replaceOptimisticReportWithActualReport', () => { it('should merge optimistic DM report into preexisting report without draft comment', async () => { const reportID = '1'; const preexistingReportID = '2'; + const optimisticAccountID = 1; + const existingAccountID = 2; // Create optimistic and preexisting reports const optimisticReport = createRandomReport(Number(reportID), undefined); optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; optimisticReport.reportName = 'Optimistic Report'; - optimisticReport.participants = {'1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + optimisticReport.participants = {[optimisticAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; const existingReport = createRandomReport(Number(preexistingReportID), undefined); existingReport.reportID = preexistingReportID; existingReport.reportName = 'Existing Report'; - existingReport.participants = {'2': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + existingReport.participants = {[existingAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, optimisticReport); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, existingReport); @@ -132,12 +134,13 @@ describe('replaceOptimisticReportWithActualReport', () => { it('should handle preexisting report when participants is undefined in existing report', async () => { const reportID = '1'; const preexistingReportID = '2'; + const accountID = 1; // Create optimistic report with participants const optimisticReport = createRandomReport(Number(reportID), undefined); optimisticReport.reportID = reportID; optimisticReport.preexistingReportID = preexistingReportID; - optimisticReport.participants = {'1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; + optimisticReport.participants = {[accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}; // Create existing report without participants const existingReport = createRandomReport(Number(preexistingReportID), undefined); From 651ad02be06126a81ddc892dd9668d0ad7b0d66a Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 23 Jan 2026 23:00:36 +0700 Subject: [PATCH 09/10] update connectWithoutView --- src/libs/actions/replaceOptimisticReportWithActualReport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 9feeb4b82ed1..ae8080003306 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -41,7 +41,7 @@ Onyx.connectWithoutView({ let allReports: OnyxCollection; const allReportActions: OnyxCollection = {}; -Onyx.connect({ +Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { if (!key || !actions) { From 52bdae87e19590d9d742814733abbf6f90dc8f36 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Sun, 25 Jan 2026 22:34:37 +0700 Subject: [PATCH 10/10] add comment --- src/libs/actions/replaceOptimisticReportWithActualReport.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index ae8080003306..f37e7932189f 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -1,13 +1,13 @@ import {DeviceEventEmitter, InteractionManager} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {saveReportDraftComment} from '@libs/actions/Report'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {isMoneyRequest, isMoneyRequestReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Report, ReportActions} from '@src/types/onyx'; +import {saveReportDraftComment} from './Report'; /** * replaceOptimisticReportWithActualReport @@ -32,6 +32,7 @@ import type {Report, ReportActions} from '@src/types/onyx'; */ let allReportDraftComments: Record = {}; +// Draft comments are cached only for transferring to the preexisting report; no UI subscribes, so connectWithoutView() is used. Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, waitForCollectionCallback: true, @@ -41,6 +42,7 @@ Onyx.connectWithoutView({ let allReports: OnyxCollection; const allReportActions: OnyxCollection = {}; +// Report actions are cached only to resolve parent actions for IOU cleanup; no UI subscribes, so connectWithoutView() is used. Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { @@ -144,6 +146,7 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm }); } +// Reports are observed only to detect preexistingReportID and run replacement; no UI subscribes, so connectWithoutView() is used. Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true,