Skip to content
Merged
2 changes: 2 additions & 0 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {confirmReadyToOpenApp, openApp, updateLastRoute} from './libs/actions/Ap
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';
Expand Down
4 changes: 1 addition & 3 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ import {unholdRequest} from './actions/IOU/Hold';
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';
Expand Down Expand Up @@ -1082,8 +1082,6 @@ Onyx.connectWithoutView({
return acc;
}

handlePreexistingReport(report);

// Get all reports, which are the ones that are:
// - Owned by the same user
// - Are either open or submitted
Expand Down
107 changes: 1 addition & 106 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
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';
Expand Down Expand Up @@ -161,8 +161,6 @@
isGroupChat as isGroupChatReportUtils,
isHiddenForCurrentUser,
isIOUReportUsingReport,
isMoneyRequest,
isMoneyRequestReport,
isOpenExpenseReport,
isProcessingReport,
isReportManuallyReimbursed,
Expand All @@ -182,8 +180,6 @@
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,
Expand Down Expand Up @@ -284,7 +280,7 @@
/** @deprecated This value is deprecated and will be removed soon after migration. Use the email from useCurrentUserPersonalDetails hook instead. */
let deprecatedCurrentUserLogin: string | undefined;

Onyx.connect({

Check warning on line 283 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val is undefined
Expand All @@ -298,7 +294,7 @@
},
});

Onyx.connect({

Check warning on line 297 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CONCIERGE_REPORT_ID,
callback: (value) => (conciergeReportID = value),
});
Expand All @@ -306,7 +302,7 @@
// map of reportID to all reportActions for that report
const allReportActions: OnyxCollection<ReportActions> = {};

Onyx.connect({

Check warning on line 305 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
callback: (actions, key) => {
if (!key || !actions) {
Expand All @@ -318,7 +314,7 @@
});

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 317 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -327,7 +323,7 @@
});

let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
Onyx.connect({

Check warning on line 326 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand All @@ -342,7 +338,7 @@
});

let onboarding: OnyxEntry<Onboarding>;
Onyx.connect({

Check warning on line 341 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ONBOARDING,
callback: (val) => {
if (Array.isArray(val)) {
Expand All @@ -353,18 +349,11 @@
});

let introSelected: OnyxEntry<IntroSelected> = {};
Onyx.connect({

Check warning on line 352 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_INTRO_SELECTED,
callback: (val) => (introSelected = val),
});

let allReportDraftComments: Record<string, string | undefined> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
waitForCollectionCallback: true,
callback: (value) => (allReportDraftComments = value),
});

let environment: EnvironmentType;
getEnvironment().then((env) => {
environment = env;
Expand Down Expand Up @@ -1979,99 +1968,6 @@
Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus);
}

function handlePreexistingReport(report: Report) {
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
const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`];
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,
Expand Down Expand Up @@ -6633,7 +6529,6 @@
getNewerActions,
getOlderActions,
getReportPrivateNote,
handlePreexistingReport,
handleUserDeletedLinksInHtml,
hasErrorInPrivateNotes,
inviteToGroupChat,
Expand Down
172 changes: 172 additions & 0 deletions src/libs/actions/replaceOptimisticReportWithActualReport.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

move it action folder.

Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {DeviceEventEmitter, InteractionManager} from 'react-native';
import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
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
*
* 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<string, string | undefined> = {};
// 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,
callback: (value) => (allReportDraftComments = value ?? {}),
});

let allReports: OnyxCollection<Report>;

const allReportActions: OnyxCollection<ReportActions> = {};
// Report actions are cached only to resolve parent actions for IOU cleanup; no UI subscribes, so connectWithoutView() is used.
Onyx.connectWithoutView({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Each ConnectWithoutView needs a comment on why we are using this. Can you please add one liner comments one each

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);
});
}

// Reports are observed only to detect preexistingReportID and run replacement; no UI subscribes, so connectWithoutView() is used.
Onyx.connectWithoutView({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is great, I really love seeing this here. Would you mind adding some pretty verbose comments to explain what the purpose of this is? I don't think I really understand why we have handlePreexistingReport() in the first place.

key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value: OnyxCollection<Report>) => {
allReports = value;

if (!value) {
return;
}

for (const report of Object.values(value)) {
if (!report) {
continue;
}

replaceOptimisticReportWithActualReport(report, allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]);
}
},
});

export {replaceOptimisticReportWithActualReport};

export default {};
Loading
Loading