Skip to content

fix: offline merge visibility for track distance expenses#77862

Closed
Bar-Maz wants to merge 44 commits intoExpensify:mainfrom
callstack-internal:fix/75267-offline-mode-distance-merge
Closed

fix: offline merge visibility for track distance expenses#77862
Bar-Maz wants to merge 44 commits intoExpensify:mainfrom
callstack-internal:fix/75267-offline-mode-distance-merge

Conversation

@Bar-Maz
Copy link

@Bar-Maz Bar-Maz commented Dec 17, 2025

Explanation of Change

Fixed an issue where track distance expenses were not visible in expense reports after merging transactions while offline, causing skeleton loaders and empty reports.

Root Cause:
The transaction filtering logic for expense reports excluded all transactions with pending actions, even when offline. After merging a distance expense offline, the transaction had pendingAction: ADD, which caused it to be filtered out, resulting in skeleton loaders and empty reports.

Solution:

  • Extracted transaction filtering logic into dedicated functions that properly handle offline mode
  • Updated filtering to include transactions with ADD pending action when offline (they're excluded when online)
  • Updated ReportActionsView to use the new filtering logic for expense reports

Result:
Merged expenses are now immediately visible in expense reports when offline, without skeleton loaders or empty reports.

Fixed Issues

$ #75267
PROPOSAL:

Tests

  1. Go to Workspace chat -> Create a distance expense (call ExpenseWS)
  2. Go to Self DM -> Create a distance expense (call ExpenseDM)
  3. Open distance expense in self DM
  4. Tap More button >> Merge option >> select the displayed distance expense >> tap Continue
  5. Select the merchant details of workspace distance expense (ExpenseWS)
  6. Tap on Merge expenses
  7. Merge completes successfully
  8. Select the workspace distance expense in LHN
  9. Report displays correctly with the merged expense, not empty

Offline tests

  1. Go to Workspace chat -> Create a distance expense (call ExpenseWS)
  2. Go to Self DM -> Create a distance expense (call ExpenseDM)
  3. Open distance expense in self DM
  4. Go offline
  5. Tap More button >> Merge option >> select the displayed distance expense >> tap Continue
  6. Select the merchant details of workspace distance expense (ExpenseWS)
  7. Tap on Merge expenses
  8. Merge completes successfully without infinite skeleton loading
  9. No console errors related to missing report data
  10. Report type is preserved (EXPENSE type)
  11. Select the workspace distance expense in LHN
  12. Report displays correctly with the merged expense, not empty
  13. Go back online
  14. Merge syncs with server and data remains consistent

QA Steps

  1. Navigate to staging.new.expensify.com
  2. Go to Workspace chat -> Create a distance expense (call ExpenseWS)
  3. Go to Self DM -> Create a distance expense (call ExpenseDM)
  4. Open distance expense in self DM
  5. Go offline
  6. Tap More button >> Merge option >> select the displayed distance expense >> tap Continue
  7. Select the merchant details of workspace distance expense (ExpenseWS)
  8. Tap on Merge expenses
  9. Merge completes successfully without infinite skeleton loading
  10. Select the workspace distance expense in LHN
  11. Report displays correctly with the merged expense, not empty
  12. Go back online and verify the merge syncs correctly with the server
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

@github-actions
Copy link
Contributor

github-actions bot commented Dec 17, 2025

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Bar-Maz Bar-Maz force-pushed the fix/75267-offline-mode-distance-merge branch from ab9d95a to f5aea4e Compare December 17, 2025 09:55
@codecov
Copy link

codecov bot commented Dec 17, 2025

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/libs/ModifiedExpenseMessage.ts 53.40% <ø> (ø)
src/libs/ReportNameUtils.ts 82.23% <ø> (ø)
src/libs/ReportUtils.ts 71.98% <ø> (-0.34%) ⬇️
src/libs/actions/IOU/index.ts 68.84% <100.00%> (+0.67%) ⬆️
src/libs/actions/Report.ts 54.65% <ø> (+0.73%) ⬆️
src/pages/home/report/ReportActionsList.tsx 80.72% <100.00%> (ø)
src/pages/home/report/withReportOrNotFound.tsx 87.87% <100.00%> (+0.37%) ⬆️
src/libs/ReportActionsUtils.ts 55.50% <94.11%> (+0.76%) ⬆️
src/libs/actions/MergeTransaction.ts 63.26% <94.52%> (+15.57%) ⬆️
.../TransactionMerge/MergeTransactionsListContent.tsx 0.00% <0.00%> (ø)
... and 1 more
... and 266 files with indirect coverage changes

@Bar-Maz Bar-Maz force-pushed the fix/75267-offline-mode-distance-merge branch from f5aea4e to d179e80 Compare December 17, 2025 10:07
"electron-builder": "26.0.19",
"eslint": "^9.36.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-expensify": "^2.0.101",
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this change here?

Comment on lines +159 to +167
const shouldShowLoadingPlaceholder = useMemo(() => {
if (isOffline) {
return false;
}
if (Array.isArray(eligibleTransactions)) {
return false;
}
return true;
}, [isOffline, eligibleTransactions]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Will there be any benefit from this instead just
const shouldShowLoadingPlaceholder = !isOffline && !Array.isArray(eligibleTransactions);

Comment on lines +120 to +123
const reportTransactionIDs = useMemo(() => {
const allTransactions = getAllNonDeletedTransactions(reportTransactions, allReportActions ?? []);
return allTransactions.map((transaction) => transaction.transactionID);
}, [reportTransactions, allReportActions]);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see a point for creating temporary allTransactions here, I guess it could be a big one. If its the same in result, maybe lets use previous one?

),
[reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, policy],
);
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true});
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm bit worried about this allTransactions and visibleReportActions. Let's check how it performs with heavy account. I think there could be performance drop if we use it in dep array with such logic like this one

}, [canShowHeader, retryLoadNewerChatsError]);

const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED);
const shouldShowSkeleton = isOffline && sortedVisibleReportActions.length === 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this part of the solution? The previous condition looks like a solution to a specific use case.


const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED);
const shouldShowSkeleton = isOffline && sortedVisibleReportActions.length === 0;

Copy link
Contributor

Choose a reason for hiding this comment

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

additional empty line

[oldIOUAction.reportActionID]: null,
},
});

Copy link
Contributor

Choose a reason for hiding this comment

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

trailing line

const isIOUReportExpense = isExpenseReport(iouReport);

if (isIOUReportExpense) {
const expenseReportTransactions = Object.values(allTransactions ?? {}).filter((transaction) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is allTransactions on every filter iteration reportActions.filter((reportAction) and it looks quite complex. Maybe it would be better to create some utility function for it or even few of them (with tests ideally if possible)? I think it will be hard to test and maintain in this shape

);

if (eligibleTransactions?.length === 0) {
const shouldShowLoadingPlaceholder = useMemo(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

we should avoid using useMemo - compiler would mark this as an error

@Bar-Maz Bar-Maz force-pushed the fix/75267-offline-mode-distance-merge branch 4 times, most recently from 9a62f9a to 7a6ae71 Compare January 8, 2026 02:02
@VickyStash
Copy link
Contributor

Hey @Bar-Maz, please make sure to resolve all of the checks failures!

@Bar-Maz
Copy link
Author

Bar-Maz commented Jan 12, 2026

I have read the CLA Document and I hereby sign the CLA

CLABotify added a commit to Expensify/CLA that referenced this pull request Jan 12, 2026
@Bar-Maz Bar-Maz force-pushed the fix/75267-offline-mode-distance-merge branch 2 times, most recently from ad232c3 to d11d715 Compare January 12, 2026 21:46
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const reportExists = !!report;

if (isOffline && hasReportActions && reportExists) {
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we need this change? If we want to create an optimistic report, it would be better if we can do it in Transaction_Merge API.

Copy link
Author

Choose a reason for hiding this comment

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

I've moved the optimistic report creation logic from openReport to mergeTransactionRequest in MergeTransaction.ts.

};
const isOffline = NetworkStore.isOffline();
const hasReportActions = reportActionsExist(reportID);
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, we want to avoid using allReports here. We already had many issues to remove it, like #66411

Copy link
Author

Choose a reason for hiding this comment

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

I've modified this function so it no longer adds any new reliance on allReports.

@dukenv0307
Copy link
Contributor

@Bar-Maz Some lints are failing

}
const reportID = mergeTransaction.reportID;

// Ensure expense report exists in Onyx before merge (required by getUpdateMoneyRequestParams)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you please share the steps to reproduce this issue? I think expenseReport appears in the confirmation page.

Copy link
Author

Choose a reason for hiding this comment

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

After testing, I found that the expense report is always present in Onyx at this point. I was worried that it might not be fully loaded if we, for example, just logged in. This part turned out to be unnecessary, so I removed it.

import Parser from './Parser';
import {arePersonalDetailsMissing, getEffectiveDisplayName, getPersonalDetailByEmail, getPersonalDetailsByIDs} from './PersonalDetailsUtils';
import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
// eslint-disable-next-line import/no-cycle
Copy link
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
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
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

@dukenv0307
Copy link
Contributor

Testing now

@dukenv0307
Copy link
Contributor

Select the merchant details of workspace distance expense (ExpenseWS)

If we select the details of SelfDM, the workspace expense will show infinite loading

Screen.Recording.2026-01-22.at.11.01.44.mov

Copy link
Contributor

@heyjennahay heyjennahay left a comment

Choose a reason for hiding this comment

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

Product review not required

@Bar-Maz
Copy link
Author

Bar-Maz commented Jan 26, 2026

Hi, @dukenv0307
Issue 75267 appears to be resolved on the current main branch by this pull request: #78966.
Could you please confirm that this is correct?

However, while working on this issue, I noticed that after the offline merge, the workspace and chat don’t seem to update properly on the current main branch. In fact, most of the changes in this PR are addressing that behavior. I can fix this so that chat updates are reflected correctly as well, based on the work already done here.

After consulting with @VickyStash, I have a clearer idea of a few improvements we could make to the logic.

Do we want to correct this behavior? If so, what’s the preferred approach? Should I continue in this PR, or open a new one?

I’ve attached a video demonstrating the current state and the mentioned issue.

offline_merge_main.mp4

@dukenv0307
Copy link
Contributor

Is it expected to show the loading indicator here?

  1. Track the distance expense in Self DM
  2. Track another distance expense in WS (Workspace)
  3. Go to the WS expense detail
  4. Go offline
  5. Select more
  6. Merge expense
  7. Select the details of WS expense
  8. Complete merging
  9. Go to SelfDM
  10. Observe that the distance expense created from step 1 shows infinite loading
Screen.Recording.2026-01-27.at.09.22.20.mov

@dukenv0307
Copy link
Contributor

Do we want to correct this behavior? If so, what’s the preferred approach? Should I continue in this PR, or open a new one?

I think we should do it in another PR because this one is quite big. Can you please update the tests/expectations to cover the following case

  1. Select all details from the workspace expense
  2. Select all details from the selfDM expense

@Bar-Maz
Copy link
Author

Bar-Maz commented Jan 27, 2026

I think we should do it in another PR because this one is quite big.

I agree that this PR is quite big. However, the original issue (the skeleton loader after merge) already seems to be resolved on main.

What remains (and what most of this PR is addressing) is the inconsistent chat/workspace update behavior after offline merge, which can still be observed on main as well. So at this point the only meaningful work here would be to actually fix and align that chat behavior with the online flow.

If we don’t want to extend the scope of this issue, we could close it as resolved and open a new issue specifically for the offline-merge chat/workspace update behavior, and continue the work there based on the changes already made in this PR.

@dukenv0307
Copy link
Contributor

dukenv0307 commented Jan 28, 2026

If we don’t want to extend the scope of this issue

@Bar-Maz I don't think we should add more changes to this PR. Ideally, we can list the improvements in this PR and make the other improvements in separate PRs. cc @VickyStash for more thoughts

@VickyStash
Copy link
Contributor

@Bar-Maz I don't think we should add more changes to this PR. Ideally, we can list the improvements in this PR and make the other improvements in separate PRs. cc @VickyStash for more thoughts

I agree that this PR is already quite big and complex.

  1. Do we know for sure that the original issue is fixed (the one was reported in the original issue)?
  2. And besides that we have some additional issues on main that now are visible, right? :

However, while working on this issue, I noticed that after the offline merge, the workspace and chat don’t seem to update properly on the current main branch. In fact, most of the changes in this PR are addressing that behavior. I can fix this so that chat updates are reflected correctly as well, based on the work already done here

@Bar-Maz does the PR fixes this issue right now (without any furthur adjustments?). Does it fix it in a way so optimistic data is aligned with the data received from the BE later? If any adjustments are needed, I think it's maybe better to have a new PR.

I think it worth to check:

  • if there any issue opened for the additional issue you are trying to fix
  • is there any open PRs for that/ that can fix that

Cause I see there are some PRs, that are applying fixes in the same area, does any of it fixes the issue you mention?
#72990
#79766

@dukenv0307
Copy link
Contributor

Any updates @Bar-Maz ?

@Bar-Maz
Copy link
Author

Bar-Maz commented Feb 23, 2026

Hi, I'll close it as I'm preparing a separate PR that solves the issue

@Bar-Maz Bar-Maz closed this Feb 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants