[$250] Expense - Three expenses appears when delete split expense on hold offline
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:
- Create expense in workspace chat
- Open expense thread
- Go offline
- Hold the expense
- Split the expense
- 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
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 Owner
Current Issue Owner: @hoangzinh
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.
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'sMoneyRequestReportPreviewviauseReportWithTransactionsAndViolations. - The component DOES re-render (4 times during the delay), but with stale data because
Onyx.update()is async andpendingAction: DELETEhasn't propagated yet.
Flow Analysis:
-
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 -
For 2-split โ 1-split deletion,
updateSplitTransactions()is called: https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useDeleteTransactions.ts#L114 -
Inside
updateSplitTransactions(), when reverting to single expense, it callsAPI.write(REVERT_SPLIT_TRANSACTION): https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/libs/actions/IOU.ts#L15089 -
API.write()callsOnyx.update(optimisticData)inprepareRequest(): https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/libs/API/index.ts#L105 -
Onyx.update()is asynchronous - there's a ~645ms delay beforependingAction: DELETEpropagates to subscribers. -
During this delay,
MoneyRequestReportPreviewre-renders viauseReportWithTransactionsAndViolations. When offline, this hook intentionally shows ALL transactions (including those withpendingAction: DELETE) for Pattern B UX: https://github.com/Expensify/App/blob/df5234bd3120bd81727c7e764f5af88dc878348d/src/hooks/useReportWithTransactionsAndViolations.ts#L21-L24 -
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)
- Making
Onyx.update()synchronous - Not feasible as async behavior is by design for performance. - Adding delay before re-render - This would be a hack/workaround, not a proper solution.
- Modifying
useReportWithTransactionsAndViolationsto check a separate "pending delete" flag - More complex and unnecessary when proper sequencing solves the issue.
Job added to Upwork: https://www.upwork.com/jobs/~021989057137075614146
Triggered auto assignment to Contributor-plus team member for initial proposal review - @Pujan92 (External)
@TaduJR can you provide a working demo or test branch of the fix?
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
@Pujan92 Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!
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
I will review tomorrow.
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
@Pujan92 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
Hey @Pujan92 have you gotten a chance to review?
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
Triggered auto assignment to Contributor-plus team member for initial proposal review - @hoangzinh (External)
Sorry @lydiabarclay, this was missed by me. As you re-added the external label, let @hoangzinh review as they are assigned.
Yes, I will review proposals today
@TaduJR can you help me understand those points?
- MEANWHILE: SplitExpensePage re-renders immediately before async updates are applied
Can you confirm it's SplitExpensePage, instead of ReportScreen page?
- 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?
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
@hoangzinh Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
@TaduJR can you please respond to @hoangzinh 's questions above? ty!
Hey @lydiabarclay
Very sorry, I missed this one somehow.
Investigating...
Thanks for the Bump!
@hoangzinh 6 days overdue. This is scarier than being forced to listen to Vogon poetry!
๐จ 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:
-
getChildTransactions()insrc/libs/TransactionUtils/index.tscorrectly filters out split child transactions withpendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE(line 2327). -
getAllNonDeletedTransactions()insrc/libs/MoneyRequestReportUtils.tswas including transactions withpendingAction === DELETEwhen 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 withpendingAction === DELETEviagetDeleteTrackExpenseInformation(). - However,
getAllNonDeletedTransactions()was returningtruefor these deleted transactions whenisOffline === true, causing them to still appear in the transaction list. - This created an inconsistency where
getChildTransactions()would filter them out, butgetAllNonDeletedTransactions()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)
@hoangzinh Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!
Reviewing proposals
@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
@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
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ