App icon indicating copy to clipboard operation
App copied to clipboard

[$250] Expense - Three expenses appears when delete split expense on hold offline

Open jponikarchuk opened this issue 2 months ago โ€ข 30 comments

If you havenโ€™t already, check out our contributing guidelines for onboarding and email [email protected] to request to join our Slack channel!


Version Number: v9.2.47-0 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack: n/a Email or phone of affected tester (no customers): [email protected] Issue reported by: Applause Internal Team Device used: Lenovo 80ES / Windows 10 Pro App Component: Chat Report View

Action Performed:

  1. Create expense in workspace chat
  2. Open expense thread
  3. Go offline
  4. Hold the expense
  5. Split the expense
  6. Delete one of the expenses

Expected Result:

Two expenses should be displayed, or only one expense should be displayed

Actual Result:

Three expenses appears

Workaround:

Unknown

Platforms:

  • [ ] Android: App
  • [ ] Android: mWeb Chrome
  • [ ] iOS: App
  • [ ] iOS: mWeb Safari
  • [ ] iOS: mWeb Chrome
  • [x] Windows: Chrome
  • [ ] MacOS: Chrome / Safari
  • [ ] MacOS: Desktop

Screenshots/Videos

https://github.com/user-attachments/assets/e301c175-fcf4-4cf0-9bb1-0f879b6ffbff

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~021989057137075614146
  • Upwork Job ID: 1989057137075614146
  • Last Price Increase: 2025-12-25
Issue OwnerCurrent Issue Owner: @hoangzinh

jponikarchuk avatar Nov 10 '25 07:11 jponikarchuk

Triggered auto assignment to @lydiabarclay (Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.

melvin-bot[bot] avatar Nov 10 '25 07:11 melvin-bot[bot]

Proposal

Please re-state the problem that we are trying to solve in this issue.

When deleting one split from a 2-split expense while offline, the UI briefly shows 3 expenses instead of 1.

What is the root cause of that problem?

The root cause is a timing/ordering issue between when Onyx.update() is called and when the pendingAction: DELETE state propagates to components.

Key Clarifications:

  • The component showing "3 expenses" is NOT SplitExpensePage (it stops rendering after delete). It's MoneyRequestReportPreview via useReportWithTransactionsAndViolations.
  • The component DOES re-render (4 times during the delay), but with stale data because Onyx.update() is async and pendingAction: DELETE hasn't propagated yet.

Flow Analysis:

  1. User deletes 1 of 2 splits while offline, triggering deleteTransactions() which processes split deletions: https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useDeleteTransactions.ts#L96-L135

  2. For 2-split โ†’ 1-split deletion, updateSplitTransactions() is called: https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useDeleteTransactions.ts#L114

  3. Inside updateSplitTransactions(), when reverting to single expense, it calls API.write(REVERT_SPLIT_TRANSACTION): https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/libs/actions/IOU.ts#L15089

  4. API.write() calls Onyx.update(optimisticData) in prepareRequest(): https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/libs/API/index.ts#L105

  5. Onyx.update() is asynchronous - there's a ~645ms delay before pendingAction: DELETE propagates to subscribers.

  6. During this delay, MoneyRequestReportPreview re-renders via useReportWithTransactionsAndViolations. When offline, this hook intentionally shows ALL transactions (including those with pendingAction: DELETE) for Pattern B UX: https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useReportWithTransactionsAndViolations.ts#L21-L24

  7. The problem: During the ~645ms delay, transactions have stale pendingAction: "add" instead of "delete", so 3 expenses render WITHOUT strikethrough styling.

What changes do you think we should make in order to solve the problem?

Pre-apply pendingAction: DELETE synchronously via await Onyx.multiSet() BEFORE calling updateSplitTransactions(). This ensures the DELETE state is persisted before triggering re-renders.

Change in useDeleteTransactions.ts:

https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useDeleteTransactions.ts#L96-L114

for (const transactionID of Object.keys(splitTransactionsByOriginalTransactionID)) {
    const splitIDs = new Set((splitTransactionsByOriginalTransactionID[transactionID] ?? []).map((transaction) => transaction.transactionID));
    const childTransactions = getChildTransactions(allTransactions, allReports, transactionID).filter(
        (transaction) => !splitIDs.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID)),
    );

    if (childTransactions.length === 0) {
        nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]);
        continue;
    }

    // Pre-apply DELETE pendingAction before calling updateSplitTransactions
    const transactionsToMark: Record<string, Partial<Transaction>> = {};
    splitIDs.forEach((splitID) => {
        transactionsToMark[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitID}`] = {
            pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
        };
    });
    await Onyx.multiSet(transactionsToMark);

    // Now components will see DELETE state immediately when they re-render
    updateSplitTransactions({
        // ... existing params
    });
}

This is a root cause fix because:

  • The actual problem is incorrect ordering: we trigger re-renders before critical DELETE state is applied
  • The fix ensures proper sequencing by awaiting the state update before dependent operations
  • This follows the correct Onyx pattern for offline-first optimistic updates

What alternative solutions did you explore? (Optional)

  1. Making Onyx.update() synchronous - Not feasible as async behavior is by design for performance.
  2. Adding delay before re-render - This would be a hack/workaround, not a proper solution.
  3. Modifying useReportWithTransactionsAndViolations to check a separate "pending delete" flag - More complex and unnecessary when proper sequencing solves the issue.

TaduJR avatar Nov 10 '25 09:11 TaduJR

Job added to Upwork: https://www.upwork.com/jobs/~021989057137075614146

melvin-bot[bot] avatar Nov 13 '25 19:11 melvin-bot[bot]

Triggered auto assignment to Contributor-plus team member for initial proposal review - @Pujan92 (External)

melvin-bot[bot] avatar Nov 13 '25 19:11 melvin-bot[bot]

@TaduJR can you provide a working demo or test branch of the fix?

Pujan92 avatar Nov 18 '25 18:11 Pujan92

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Nov 20 '25 16:11 melvin-bot[bot]

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Nov 27 '25 16:11 melvin-bot[bot]

@Pujan92 Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!

melvin-bot[bot] avatar Nov 28 '25 00:11 melvin-bot[bot]

Hey @Pujan92

Very sorry for the delay. Email got buried.

Here is the Demo

https://github.com/user-attachments/assets/77e3d139-1693-4ac0-9004-96cbda44c38e

TaduJR avatar Nov 30 '25 14:11 TaduJR

I will review tomorrow.

Pujan92 avatar Dec 01 '25 12:12 Pujan92

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Dec 04 '25 16:12 melvin-bot[bot]

@Pujan92 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

melvin-bot[bot] avatar Dec 04 '25 23:12 melvin-bot[bot]

Hey @Pujan92 have you gotten a chance to review?

lydiabarclay avatar Dec 05 '25 23:12 lydiabarclay

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Dec 11 '25 16:12 melvin-bot[bot]

Triggered auto assignment to Contributor-plus team member for initial proposal review - @hoangzinh (External)

melvin-bot[bot] avatar Dec 12 '25 00:12 melvin-bot[bot]

Sorry @lydiabarclay, this was missed by me. As you re-added the external label, let @hoangzinh review as they are assigned.

Pujan92 avatar Dec 12 '25 13:12 Pujan92

Yes, I will review proposals today

hoangzinh avatar Dec 15 '25 10:12 hoangzinh

@TaduJR can you help me understand those points?

  1. MEANWHILE: SplitExpensePage re-renders immediately before async updates are applied

Can you confirm it's SplitExpensePage, instead of ReportScreen page?

  1. Calls getChildTransactions which reads stale data (transactions don't have pendingAction: DELETE yet)

Can you help me understand why Onyx is updated, but the page/component doesnโ€™t re-render?

hoangzinh avatar Dec 15 '25 15:12 hoangzinh

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Dec 18 '25 16:12 melvin-bot[bot]

@hoangzinh Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

melvin-bot[bot] avatar Dec 18 '25 23:12 melvin-bot[bot]

@TaduJR can you please respond to @hoangzinh 's questions above? ty!

lydiabarclay avatar Dec 18 '25 23:12 lydiabarclay

Hey @lydiabarclay

Very sorry, I missed this one somehow.

Investigating...

Thanks for the Bump!

TaduJR avatar Dec 19 '25 09:12 TaduJR

Hey @hoangzinh

Update the Proposal with your questions included

TaduJR avatar Dec 22 '25 08:12 TaduJR

@hoangzinh 6 days overdue. This is scarier than being forced to listen to Vogon poetry!

melvin-bot[bot] avatar Dec 22 '25 23:12 melvin-bot[bot]

๐Ÿšจ Edited by proposal-police: This proposal was edited at 2025-12-26 06:13:50 UTC.

Proposal

Please re-state the problem that we are trying to solve in this issue.

Two expenses should be displayed, or only one expense should be displayed

What is the root cause of that problem?

The issue stems from inconsistent filtering logic between two key functions that handle transaction display:

  1. getChildTransactions() in src/libs/TransactionUtils/index.ts correctly filters out split child transactions with pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE (line 2327).

  2. getAllNonDeletedTransactions() in src/libs/MoneyRequestReportUtils.ts was including transactions with pendingAction === DELETE when the app is offline (line 90-91 in the original code). This function is used throughout the app to determine which transactions to display in report views.

When a split expense is deleted while offline:

  • The deletion process in updateSplitTransactions() correctly marks the transaction with pendingAction === DELETE via getDeleteTrackExpenseInformation().
  • However, getAllNonDeletedTransactions() was returning true for these deleted transactions when isOffline === true, causing them to still appear in the transaction list.
  • This created an inconsistency where getChildTransactions() would filter them out, but getAllNonDeletedTransactions() would include them, leading to duplicate or incorrect expense counts.

Additionally, there's a potential edge case where draft transactions might not have pendingAction === DELETE set immediately when removing from the draft's splitExpenses array via removeSplitExpenseField(), though this is typically handled when the split is saved.

What changes do you think we should make in order to solve the problem?

The solution is to update getAllNonDeletedTransactions() to exclude split child transactions (transactions with originalTransactionID) that have pendingAction === DELETE, even when offline. This ensures consistency with getChildTransactions() and prevents deleted split expenses from appearing in the transaction list. Update getAllNonDeletedTransactions() in src/libs/MoneyRequestReportUtils.ts:

Add a check to exclude split child transactions with pending DELETE action before the general offline handling:

function getAllNonDeletedTransactions(transactions: OnyxCollection<Transaction>, reportActions: ReportAction[], isOffline = false, includeOrphanedTransactions = false) {
    return Object.values(transactions ?? {}).filter((transaction): transaction is Transaction => {
        if (!transaction) {
            return false;
        }

        // Exclude split child transactions with pending DELETE action, even when offline
        // This ensures consistency with getChildTransactions which filters these out
        const isSplitChildTransaction = !!transaction?.comment?.originalTransactionID;
        if (isSplitChildTransaction && transaction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
            return false;
        }

        if (transaction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
            return true;
        }

        const action = getIOUActionForTransactionID(reportActions, transaction.transactionID);
        if (!action && includeOrphanedTransactions) {
            return true;
        }
        if (action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isOffline) {
            return true;
        }
        return !isDeletedParentAction(action) && (reportActions.length === 0 || !isDeletedAction(action));
    });
}

https://github.com/user-attachments/assets/33d99031-2b11-43aa-a2f1-52bc93334b22

What alternative solutions did you explore? (Optional)

annaweber830 avatar Dec 23 '25 04:12 annaweber830

@hoangzinh Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!

melvin-bot[bot] avatar Dec 24 '25 23:12 melvin-bot[bot]

Reviewing proposals

hoangzinh avatar Dec 25 '25 10:12 hoangzinh

@TaduJR After reviewing, I don't think your RCA is correct. And your solution won't solve this issue properly. The reason why your solution looks work, because multiSet will clear the deleted split transaction and just leave {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, hence it won't be found in the money report

Image

hoangzinh avatar Dec 25 '25 15:12 hoangzinh

@annaweber830 Thanks for posting proposal. Can you help me understand why this issue relates to SplitExpensePage.tsx, isn't it MoneyRequestReportTransactionList? And can you share recording to prove your solution work? TY

hoangzinh avatar Dec 25 '25 15:12 hoangzinh

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] avatar Dec 25 '25 16:12 melvin-bot[bot]