Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
574ebd5
fix: offline merge visibility for track distance expenses
Bar-Maz Dec 17, 2025
7228ff6
refactor: optimize expense report transaction filtering and address c…
Bar-Maz Jan 7, 2026
c6e12e1
fix: prevent skeleton loader and empty report after offline merge
Bar-Maz Jan 7, 2026
db84cff
fix: ensure correct report navigation and action visibility after off…
Bar-Maz Jan 8, 2026
6929a80
fix: duplicate report expenses after offline merge
Bar-Maz Jan 9, 2026
1dc0349
fix: types for getTransactionIDsForIOUAction test
Bar-Maz Jan 9, 2026
afdc078
refactor: clean up imports and fix linting issues
Bar-Maz Jan 12, 2026
a8397a4
refactor: extract filtering logic, remove duplication and improve naming
Bar-Maz Jan 12, 2026
42c6138
fix: reset network state in openReport legacy preview test
Bar-Maz Jan 12, 2026
84bb0b3
fix: resolve variable shadowing in shouldShowReportAction callback
Bar-Maz Jan 12, 2026
747fc64
fix: rebase fixes
Bar-Maz Jan 12, 2026
5e8a6aa
fix: preserve report type and data during offline transaction merge
Bar-Maz Jan 12, 2026
a88f620
fix: lint after rebase
Bar-Maz Jan 12, 2026
a1d812a
fix: prettier
Bar-Maz Jan 12, 2026
1baf83f
fix: adjust tests
Bar-Maz Jan 12, 2026
28e1958
fix: lint
Bar-Maz Jan 13, 2026
b5a1560
fix: contact methods tests
Bar-Maz Jan 13, 2026
3872fca
refactor: remove redundant MERGE before SET when deleting source tran…
Bar-Maz Jan 13, 2026
6481605
refactor: simplify openReport by reusing single report variable
Bar-Maz Jan 13, 2026
e57575d
refactor: optimistic report creation in openReport
Bar-Maz Jan 13, 2026
ea95f53
refactor: remove unnecessary old action cleanup from new report
Bar-Maz Jan 13, 2026
0fa13c8
refactor: remove whisper action when merging transactions from DM to …
Bar-Maz Jan 13, 2026
8179697
refactor: revert openReport changes
Bar-Maz Jan 13, 2026
57eef21
refactor: remove unnecessary type fallback in IOU report update
Bar-Maz Jan 13, 2026
104414d
fix: keep UPDATE-pending transactions visible during optimistic edits
Bar-Maz Jan 13, 2026
00b1920
refactor: replace nested ternary with if-else in MergeTransaction
Bar-Maz Jan 13, 2026
48b9ca4
fix: keep UPDATE-pending transactions visible during optimistic edits
Bar-Maz Jan 13, 2026
570bae4
fix: prettier
Bar-Maz Jan 13, 2026
2604eeb
chore: merge upstream/main and resolve conflicts
Bar-Maz Jan 14, 2026
ad4a9da
refactor: CR improvements
Bar-Maz Jan 15, 2026
a571e6c
refactor: improve test verification in ReportActionsUtils
Bar-Maz Jan 15, 2026
e21d81a
fix: ReportActionsUtils tests
Bar-Maz Jan 15, 2026
35b913d
Merge remote-tracking branch 'upstream/main' into fix/75267-offline-m…
Bar-Maz Jan 19, 2026
2ebf5ca
refactor: move optimistic report creation to mergeTransactionRequest
Bar-Maz Jan 19, 2026
2605993
Merge remote-tracking branch 'upstream' into fix/75267-offline-mode-d…
Bar-Maz Jan 19, 2026
039f40d
fix: conflicts
Bar-Maz Jan 19, 2026
6fb5718
fix: lint
Bar-Maz Jan 19, 2026
d27a04f
fix: types
Bar-Maz Jan 19, 2026
81d8f01
chore: remove outdated report tests and resolve lint issues
Bar-Maz Jan 20, 2026
b358dbf
fix: lint
Bar-Maz Jan 20, 2026
b05089f
Merge remote-tracking branch 'upstream/main' into fix/75267-offline-m…
Bar-Maz Jan 20, 2026
3a97fe7
refactor: remove unnecessary report preload and add cycle import expl…
Bar-Maz Jan 21, 2026
648fba6
Merge remote-tracking branch 'upstream/main' into fix/75267-offline-m…
Bar-Maz Jan 21, 2026
7955f3d
fix: infinite loading when merging expenses to DMs
Bar-Maz Jan 22, 2026
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
4 changes: 4 additions & 0 deletions src/libs/ModifiedExpenseMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import Log from './Log';
import Parser from './Parser';
import {getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getCleanedTagName, getPolicy, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils';
// This cycle import is safe because the functions imported here don't create initialization-time dependencies.
// ModifiedExpenseMessage imports utility functions from ReportActionsUtils, and ReportActionsUtils imports utility functions from ModifiedExpenseMessage (via ReportUtils).
// The imported functions (getOriginalMessage, isModifiedExpenseAction) are pure utility functions that don't use module-level variables.
// eslint-disable-next-line import/no-cycle
import {getOriginalMessage, isModifiedExpenseAction} from './ReportActionsUtils';
// This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic.
// The functions imported here are pure utility functions that don't create initialization-time dependencies.
Expand Down
91 changes: 87 additions & 4 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
import type {ValueOf} from 'type-fest';
import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider';
import usePrevious from '@hooks/usePrevious';
import {isHarvestCreatedExpenseReport, isPolicyExpenseChat} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Card, OnyxInputOrEntry, OriginalMessageIOU, PersonalDetails, Policy, PrivatePersonalDetails, ReportMetadata, ReportNameValuePairs} from '@src/types/onyx';
import type {Card, OnyxInputOrEntry, OriginalMessageIOU, PersonalDetails, Policy, PrivatePersonalDetails, ReportMetadata, ReportNameValuePairs, Transaction} from '@src/types/onyx';
import type {
JoinWorkspaceResolution,
OriginalMessageChangeLog,
Expand Down Expand Up @@ -43,6 +42,12 @@
import Parser from './Parser';
import {arePersonalDetailsMissing, getEffectiveDisplayName, getPersonalDetailByEmail, getPersonalDetailsByIDs} from './PersonalDetailsUtils';
import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
// This cycle import is safe because the functions imported here don't create initialization-time dependencies.
// ReportActionsUtils imports utility functions from ReportUtils, and ReportUtils imports utility functions from ReportActionsUtils.
// Some functions use module-level variables (e.g., allReports, allReportActions) that are initialized asynchronously via Onyx.connect(), so there's no circular dependency during module initialization.
// eslint-disable-next-line import/no-cycle

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.

Let's add the explanation when we want to disable lint rule

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done! I'll also explore the possibility of getting rid of these cycles in a separate proposal.

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.

Can you try @libs/ReportUtils? We used before without any lint error so I think we can keep the same

import {getReportOrDraftReport, isExpenseReport, isHarvestCreatedExpenseReport, isPolicyExpenseChat} from './ReportUtils';
// eslint-disable-next-line import/no-cycle
import type {getReportName, OptimisticIOUReportAction, PartialReportAction} from './ReportUtils';
import StringUtils from './StringUtils';
import {getReportFieldTypeTranslationKey} from './WorkspaceReportFieldUtils';
Expand All @@ -66,7 +71,7 @@
type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement;

let allReportActions: OnyxCollection<ReportActions>;
Onyx.connect({

Check warning on line 74 in src/libs/ReportActionsUtils.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,
waitForCollectionCallback: true,
callback: (actions) => {
Expand All @@ -78,7 +83,7 @@
});

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

Check warning on line 86 in src/libs/ReportActionsUtils.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 @@ -87,13 +92,13 @@
});

let isNetworkOffline = false;
Onyx.connect({

Check warning on line 95 in src/libs/ReportActionsUtils.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.NETWORK,
callback: (val) => (isNetworkOffline = val?.isOffline ?? false),
});

let deprecatedCurrentUserAccountID: number | undefined;
Onyx.connect({

Check warning on line 101 in src/libs/ReportActionsUtils.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, value is undefined
Expand Down Expand Up @@ -1523,6 +1528,82 @@
return !!IOUTransactionID && reportTransactionIDs.includes(IOUTransactionID);
};

/**
* Checks if a transaction has a valid pending action for expense report filtering.
* When online, transactions with ADD or UPDATE pending action are included (to keep them visible during optimistic edits).
* When offline, transactions with ADD pending action are included.
*/
function hasValidPendingActionForExpenseReport(transaction: Transaction, isOffline: boolean): boolean {
return (
!transaction.pendingAction ||
transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ||
(!isOffline && transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE)
);
}
Comment on lines +1536 to +1542

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 Keep UPDATE-pending expense transactions visible online

The new hasValidPendingActionForExpenseReport treats any pendingAction as invalid unless it’s ADD while offline. For expense reports this feeds getExpenseReportTransactionIDs → isIOUActionMatchingTransactionList, so when a user edits an expense online (transaction gets pendingAction: UPDATE until the API returns), its IOU action will be filtered out and disappear from the report until the update finishes. Previously UPDATE-pending transactions were included, so this is a user-visible regression during optimistic edits.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed: Updated hasValidPendingActionForExpenseReport to include UPDATE pending action when online, so transactions remain visible during optimistic edits. Test updated accordingly.


/**
* Filters transactions for an expense report and returns their transaction IDs.
* When online, excludes transactions with pending actions (except ADD when offline).
*
* @param allTransactions - Collection of all transactions from Onyx
* @param iouReportID - The expense report ID to filter transactions for
* @param transactionID - Optional specific transaction ID to include if it matches the report
* @param isOffline - Whether the app is currently offline (affects which pending actions are considered valid)
* @returns Array of transaction IDs that belong to the expense report
*/
function getExpenseReportTransactionIDs(allTransactions: OnyxCollection<Transaction>, iouReportID: string, transactionID: string | undefined, isOffline: boolean): string[] {
if (!iouReportID) {
return [];
}

const expenseReportTransactions = Object.values(allTransactions ?? {}).filter((transaction) => {
if (!transaction) {
return false;
}
const matchesReportID = transaction.reportID === iouReportID;
return matchesReportID && hasValidPendingActionForExpenseReport(transaction, isOffline);
});

const transactionIDsToCheck = expenseReportTransactions.map((transaction) => transaction?.transactionID).filter((id): id is string => !!id);

if (transactionID) {
const transactionInOnyx = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
if (transactionInOnyx && transactionInOnyx.reportID === iouReportID && !transactionIDsToCheck.includes(transactionID)) {
if (hasValidPendingActionForExpenseReport(transactionInOnyx, isOffline)) {
transactionIDsToCheck.push(transactionID);
}
}
}

return transactionIDsToCheck;
}

/**
* Determines which transaction IDs should be checked for an IOU action.
*/
function getTransactionIDsForIOUAction(reportAction: ReportAction, reportTransactionIDs: string[], allTransactions: OnyxCollection<Transaction>, isOffline: boolean): string[] {
if (!isMoneyRequestAction(reportAction)) {
return reportTransactionIDs;
}

const originalMessage = getOriginalMessage(reportAction);
const iouReportID = originalMessage?.IOUReportID;
const transactionID = originalMessage?.IOUTransactionID;

if (!iouReportID || !transactionID) {
return reportTransactionIDs;
}

const iouReport = getReportOrDraftReport(iouReportID);
const isIOUReportExpense = isExpenseReport(iouReport);

if (isIOUReportExpense) {
return getExpenseReportTransactionIDs(allTransactions, iouReportID, transactionID, isOffline);
}

return reportTransactionIDs;
}

/**
* Gets the report action for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions.
* Returns a report action if there is exactly one transaction thread for the report, and undefined otherwise.
Expand Down Expand Up @@ -3331,11 +3412,11 @@
return connectionName ? translate('report.actions.type.removedConnection', {connectionName}) : '';
}

function getRenamedAction(translate: LocalizedTranslate, reportAction: OnyxEntry<ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.RENAMED>>, isExpenseReport: boolean, actorName?: string) {
function getRenamedAction(translate: LocalizedTranslate, reportAction: OnyxEntry<ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.RENAMED>>, isExpenseReportParam: boolean, actorName?: string) {

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.

Why did we rename it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Naming conflict fix. isExpenseReport was shadowing the imported function isExpenseReport from @libs/ReportUtils.

const originalMessage = getOriginalMessage(reportAction);
return translate('newRoomPage.renamedRoomAction', {
actorName,
isExpenseReport,
isExpenseReport: isExpenseReportParam,
oldName: originalMessage?.oldName ?? '',
newName: originalMessage?.newName ?? '',
});
Expand Down Expand Up @@ -3712,6 +3793,7 @@
getAllReportActions,
getCombinedReportActions,
getDismissedViolationMessageText,
getExpenseReportTransactionIDs,
getFirstVisibleReportActionID,
getIOUActionForReportID,
getIOUActionForTransactionID,
Expand Down Expand Up @@ -3744,6 +3826,7 @@
getSortedReportActions,
getSortedReportActionsForDisplay,
getTextFromHtml,
getTransactionIDsForIOUAction,
getTrackExpenseActionableWhisper,
getWhisperedTo,
hasRequestFromCurrentAccount,
Expand Down
4 changes: 4 additions & 0 deletions src/libs/ReportNameUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
import Parser from './Parser';
import {getDisplayNameOrDefault} from './PersonalDetailsUtils';
import {getCleanedTagName, getPolicy, isPolicyAdmin} from './PolicyUtils';
// This cycle import is safe because the functions imported here don't create initialization-time dependencies.
// ReportNameUtils imports utility functions from ReportActionsUtils, and ReportActionsUtils imports utility functions from ReportNameUtils (via ReportUtils).
// Some functions may use module-level variables that are initialized asynchronously via Onyx.connect(), so there's no circular dependency during module initialization.
// eslint-disable-next-line import/no-cycle
import {
getActionableCardFraudAlertResolutionMessage,
getCardIssuedMessage,
Expand Down Expand Up @@ -121,7 +125,7 @@
isTripRoom,
isUserCreatedPolicyRoom,
} from './ReportUtils';

Check warning on line 128 in src/libs/ReportNameUtils.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
let allPersonalDetails: OnyxEntry<PersonalDetailsList>;

Onyx.connect({
Expand Down
6 changes: 6 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNum
import {translateLocal} from './Localize';
import Log from './Log';
import {isEmailPublicDomain} from './LoginUtils';
// This cycle import is safe because the functions imported here are pure utility functions that don't create initialization-time dependencies.
// ReportUtils imports message formatting functions from ModifiedExpenseMessage, and ModifiedExpenseMessage imports utility functions from ReportUtils.
// eslint-disable-next-line import/no-cycle
import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage';
import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForCurrentContext';
Expand Down Expand Up @@ -171,6 +173,10 @@ import {
isSubmitAndClose,
shouldShowPolicy,
} from './PolicyUtils';
// This cycle import is safe because the functions imported here don't create initialization-time dependencies.
// ReportUtils imports utility functions from ReportActionsUtils, and ReportActionsUtils imports utility functions from ReportUtils.
// Some functions use module-level variables (e.g., allReports, allReportActions) that are initialized asynchronously via Onyx.connect(), so there's no circular dependency during module initialization.
// eslint-disable-next-line import/no-cycle
import {
formatLastMessageText,
getActionableCardFraudAlertResolutionMessage,
Expand Down
7 changes: 7 additions & 0 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@
isASAPSubmitBetaEnabled: boolean;
currentUserAccountIDParam: number;
currentUserEmailParam: string;
introSelected: OnyxEntry<OnyxTypes.IntroSelected>;

Check warning on line 761 in src/libs/actions/IOU/index.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
activePolicyID: string | undefined;
quickAction: OnyxEntry<OnyxTypes.QuickAction>;
allBetas: OnyxEntry<OnyxTypes.Beta[]>;
Expand Down Expand Up @@ -836,7 +836,7 @@
isInvoice?: boolean;
transactionThreadReportID: string | undefined;
};

Check warning on line 839 in src/libs/actions/IOU/index.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
type DeleteTrackExpenseParams = {
chatReportID: string | undefined;
chatReport: OnyxEntry<OnyxTypes.Report> | undefined;
Expand All @@ -850,7 +850,7 @@
isChatReportArchived: boolean | undefined;
isChatIOUReportArchived: boolean | undefined;
allTransactionViolationsParam: OnyxCollection<OnyxTypes.TransactionViolations>;
};

Check warning on line 853 in src/libs/actions/IOU/index.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

type DeleteMoneyRequestFunctionParams = {
transactionID: string | undefined;
Expand All @@ -859,7 +859,7 @@
violations: OnyxCollection<OnyxTypes.TransactionViolations>;
iouReport: OnyxEntry<OnyxTypes.Report>;
chatReport: OnyxEntry<OnyxTypes.Report>;
isChatIOUReportArchived?: boolean | undefined;

Check warning on line 862 in src/libs/actions/IOU/index.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
isSingleTransactionView?: boolean;
transactionIDsPendingDeletion?: string[];
selectedTransactionIDs?: string[];
Expand All @@ -873,7 +873,7 @@
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
allTransactions = {};

Check warning on line 876 in src/libs/actions/IOU/index.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
return;
}

Expand Down Expand Up @@ -4301,6 +4301,13 @@
true,
isTransactionOnHold,
);
// Set default type for reports with parentReportID and chatReportID but no type
if (!updatedMoneyRequestReport?.type && iouReport?.parentReportID && iouReport?.chatReportID) {
updatedMoneyRequestReport = {
...updatedMoneyRequestReport,
type: CONST.REPORT.TYPE.EXPENSE,
};
}
}

optimisticData.push(
Expand Down
Loading
Loading