From 0d7bf8465a6e63d24c8b018046df2289ea1f63d7 Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Mon, 18 May 2026 12:43:16 +0000 Subject: [PATCH 1/8] Fix: Navigate to fallback report when split removal empties expense report When a split expense has one child moved to a submitted report, removing the remaining child via Edit Split leaves the expense report empty but the navigation guard is not armed, causing a "Not here" page. This broadens the empty-report detection to cover the moved-child scenario alongside the existing reverse-split case. Co-authored-by: Sobit Neupane --- .../actions/IOU/SplitTransactionUpdate.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3bc205e21948..6e7c63f1a8a5 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1366,11 +1366,25 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; const expenseReportID = params.expenseReport?.reportID; - const isLastTransactionInReport = - isReverseSplitOperation && Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; + const transactionsOnExpenseReport = Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID); + const isLastTransactionInReport = isReverseSplitOperation && transactionsOnExpenseReport.length === 1; + + // Also detect when the update will empty the expense report even without a reverse split. + // This happens when a sibling split was moved to another submitted report, so + // isReverseSplitOperation is false, but the removed child is the last transaction on this report. + const remainingSplitTransactionIDs = new Set(splitExpenses.map((expense) => expense.transactionID)); + const removedChildTransactionIDs = new Set( + originalChildTransactions.filter((tx) => !!tx?.transactionID && !remainingSplitTransactionIDs.has(tx.transactionID)).map((tx) => tx!.transactionID), + ); + const willExpenseReportBeEmpty = + !isLastTransactionInReport && + !!expenseReportID && + transactionsOnExpenseReport.length > 0 && + transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && removedChildTransactionIDs.has(tx.transactionID)); + const willDeleteExpenseReport = isLastTransactionInReport || willExpenseReportBeEmpty; const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; - if (isLastTransactionInReport && fallbackReportID) { + if (willDeleteExpenseReport && fallbackReportID) { setDeleteTransactionNavigateBackUrl(ROUTES.REPORT_WITH_ID.getRoute(fallbackReportID)); } @@ -1389,7 +1403,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac params?.searchContext?.clearSelectedTransactions?.(true); } - if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { + if (isSearchPageTopmostFullScreenRoute) { Navigation.navigateBackToLastSuperWideRHPScreen(); // After the modal is dismissed, remove the transaction thread report screen @@ -1405,10 +1419,11 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac return; } - // When the reverse split deletes the expense report, use the backward navigation pattern + // When the update deletes the expense report (reverse split or removing the last child after + // a sibling was moved to a submitted report), use the backward navigation pattern // (dismissToSuperWideRHP + goBack) instead of dismissModalWithReport. This naturally pops // stale screens from the stack instead of leaving them behind. - if (isLastTransactionInReport && fallbackReportID) { + if (willDeleteExpenseReport && fallbackReportID) { const backRoute = ROUTES.REPORT_WITH_ID.getRoute(fallbackReportID); navigateBackOnDeleteTransaction(backRoute); @@ -1423,6 +1438,19 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac return; } + if (!params.transactionReport?.parentReportID) { + Navigation.navigateBackToLastSuperWideRHPScreen(); + + requestAnimationFrame(() => { + if (!transactionThreadReportScreen?.key) { + return; + } + Navigation.removeScreenByKey(transactionThreadReportScreen.key); + }); + + return; + } + const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); if (isTracking()) { From 95d16ddd78d122e7f33e18cd2d7cc77be1bf17f8 Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Mon, 18 May 2026 12:56:14 +0000 Subject: [PATCH 2/8] Fix: Replace non-null assertion with type predicate to satisfy ESLint Co-authored-by: Sobit Neupane --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 6e7c63f1a8a5..de6c4b6f2af2 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1374,7 +1374,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // isReverseSplitOperation is false, but the removed child is the last transaction on this report. const remainingSplitTransactionIDs = new Set(splitExpenses.map((expense) => expense.transactionID)); const removedChildTransactionIDs = new Set( - originalChildTransactions.filter((tx) => !!tx?.transactionID && !remainingSplitTransactionIDs.has(tx.transactionID)).map((tx) => tx!.transactionID), + originalChildTransactions + .filter((tx): tx is NonNullable & {transactionID: string} => !!tx?.transactionID && !remainingSplitTransactionIDs.has(tx.transactionID)) + .map((tx) => tx.transactionID), ); const willExpenseReportBeEmpty = !isLastTransactionInReport && From 5f7e390a0b4243db4bb53e9def18294efc08445f Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Wed, 20 May 2026 11:28:57 +0000 Subject: [PATCH 3/8] Fix: skip willExpenseReportBeEmpty when new splits are being added When the user removes all existing splits but adds new ones via "Add Split", those new transactions will be created on the same expense report, so the report will not actually be empty. Add a hasNewSplitsBeingAdded guard to prevent false-positive navigation. Co-authored-by: Sobit Neupane --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index de6c4b6f2af2..e673f5c58523 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1373,13 +1373,18 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // This happens when a sibling split was moved to another submitted report, so // isReverseSplitOperation is false, but the removed child is the last transaction on this report. const remainingSplitTransactionIDs = new Set(splitExpenses.map((expense) => expense.transactionID)); + const originalChildTransactionIDs = new Set(originalChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); const removedChildTransactionIDs = new Set( originalChildTransactions .filter((tx): tx is NonNullable & {transactionID: string} => !!tx?.transactionID && !remainingSplitTransactionIDs.has(tx.transactionID)) .map((tx) => tx.transactionID), ); + // When the user removes existing splits but adds new ones ("Add Split"), those new + // transactions will be created on the same expense report, so it won't actually be empty. + const hasNewSplitsBeingAdded = splitExpenses.some((expense) => !originalChildTransactionIDs.has(expense.transactionID)); const willExpenseReportBeEmpty = !isLastTransactionInReport && + !hasNewSplitsBeingAdded && !!expenseReportID && transactionsOnExpenseReport.length > 0 && transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && removedChildTransactionIDs.has(tx.transactionID)); From 79db9004b8131d9dba8ae81e9f71ce12352441fd Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Wed, 20 May 2026 12:22:03 +0000 Subject: [PATCH 4/8] Deduplicate requestAnimationFrame screen removal into local helper Extract the repeated requestAnimationFrame + removeScreenByKey pattern into a removeTransactionThreadReportScreen helper, replacing all four identical occurrences in updateSplitTransactionsFromSplitExpensesFlow. Co-authored-by: Sobit Neupane --- .../actions/IOU/SplitTransactionUpdate.ts | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index e673f5c58523..3a505544dc06 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1400,6 +1400,15 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const transactionThreadReportID = params.firstIOU?.childReportID; const transactionThreadReportScreen = Navigation.getReportRouteByID(transactionThreadReportID); + const removeTransactionThreadReportScreen = () => { + requestAnimationFrame(() => { + if (!transactionThreadReportScreen?.key) { + return; + } + Navigation.removeScreenByKey(transactionThreadReportScreen.key); + }); + }; + // Reset selected transactions in search after saving split expenses const searchFullScreenRoutes = navigationRef.getRootState()?.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); const lastRoute = searchFullScreenRoutes?.state?.routes?.at(-1); @@ -1412,16 +1421,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac if (isSearchPageTopmostFullScreenRoute) { Navigation.navigateBackToLastSuperWideRHPScreen(); - - // After the modal is dismissed, remove the transaction thread report screen - // to avoid navigating back to a report removed by the split transaction. - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); + removeTransactionThreadReportScreen(); return; } @@ -1433,27 +1433,14 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac if (willDeleteExpenseReport && fallbackReportID) { const backRoute = ROUTES.REPORT_WITH_ID.getRoute(fallbackReportID); navigateBackOnDeleteTransaction(backRoute); - - // Remove the transaction thread report screen to avoid navigating back to a removed report - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); + removeTransactionThreadReportScreen(); return; } if (!params.transactionReport?.parentReportID) { Navigation.navigateBackToLastSuperWideRHPScreen(); - - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); + removeTransactionThreadReportScreen(); return; } @@ -1464,15 +1451,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID); } Navigation.dismissModalWithReport({reportID: targetReportID}); - - // After the modal is dismissed, remove the transaction thread report screen - // to avoid navigating back to a report removed by the split transaction. - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); + removeTransactionThreadReportScreen(); } export {updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; From cea7307b2886225ecfd2d11ebd9191e885865867 Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Sat, 23 May 2026 17:02:26 +0000 Subject: [PATCH 5/8] Simplify willExpenseReportBeEmpty to check splitExpenses reportID directly Replace the complex removedChildTransactionIDs / originalChildTransactionIDs / hasNewSplitsBeingAdded logic with a simpler check: if no split expense targets the current expenseReportID, the report will be empty. This works because new splits created via "Add Split" inherit reportID from the draft transaction. Co-authored-by: Sobit Neupane --- .../actions/IOU/SplitTransactionUpdate.ts | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3a505544dc06..8eb5f02fdd39 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1369,25 +1369,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const transactionsOnExpenseReport = Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID); const isLastTransactionInReport = isReverseSplitOperation && transactionsOnExpenseReport.length === 1; - // Also detect when the update will empty the expense report even without a reverse split. - // This happens when a sibling split was moved to another submitted report, so - // isReverseSplitOperation is false, but the removed child is the last transaction on this report. - const remainingSplitTransactionIDs = new Set(splitExpenses.map((expense) => expense.transactionID)); - const originalChildTransactionIDs = new Set(originalChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); - const removedChildTransactionIDs = new Set( - originalChildTransactions - .filter((tx): tx is NonNullable & {transactionID: string} => !!tx?.transactionID && !remainingSplitTransactionIDs.has(tx.transactionID)) - .map((tx) => tx.transactionID), - ); - // When the user removes existing splits but adds new ones ("Add Split"), those new - // transactions will be created on the same expense report, so it won't actually be empty. - const hasNewSplitsBeingAdded = splitExpenses.some((expense) => !originalChildTransactionIDs.has(expense.transactionID)); - const willExpenseReportBeEmpty = - !isLastTransactionInReport && - !hasNewSplitsBeingAdded && - !!expenseReportID && - transactionsOnExpenseReport.length > 0 && - transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && removedChildTransactionIDs.has(tx.transactionID)); + const willExpenseReportBeEmpty = !!expenseReportID && !splitExpenses.some((expense) => expense?.reportID === expenseReportID); const willDeleteExpenseReport = isLastTransactionInReport || willExpenseReportBeEmpty; const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; From d7ad5c8eae40f889faa28b12bd54fbb52336e536 Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Sat, 23 May 2026 17:17:30 +0000 Subject: [PATCH 6/8] Add reportID to splitExpenses in navigation tests The simplified willExpenseReportBeEmpty check relies on splitExpenses having reportID set, which matches production behavior (all split expenses inherit reportID from the draft transaction). Update test fixtures to include reportID for realistic test data. Co-authored-by: Sobit Neupane --- tests/actions/IOUTest/SplitTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 62ad0e84a896..0e43399a5dc5 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2699,7 +2699,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionData: { reportID: reportID1, originalTransactionID: originalTransaction.transactionID, - splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime()}], + splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime(), reportID: reportID1}], splitExpensesTotal: undefined, }, searchContext: { @@ -2834,7 +2834,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionData: { reportID: reportID2, originalTransactionID: originalTransaction.transactionID, - splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime()}], + splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime(), reportID: reportID2}], splitExpensesTotal: undefined, }, searchContext: { From 67064acaaa1dc7259ea10bd26ebb2c5120245f22 Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Sat, 23 May 2026 17:53:12 +0000 Subject: [PATCH 7/8] Guard willExpenseReportBeEmpty against non-split transactions on report The simplified splitExpenses-only check could false-positive when the expense report contains non-split transactions alongside split children. Add a guard to verify all transactions on the report are split children before concluding the report will be empty. Co-authored-by: Sobit Neupane --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 8eb5f02fdd39..20deb1f8728c 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1369,7 +1369,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const transactionsOnExpenseReport = Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID); const isLastTransactionInReport = isReverseSplitOperation && transactionsOnExpenseReport.length === 1; - const willExpenseReportBeEmpty = !!expenseReportID && !splitExpenses.some((expense) => expense?.reportID === expenseReportID); + const originalChildIDs = new Set(originalChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); + const reportHasOnlySplitChildren = transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && originalChildIDs.has(tx.transactionID)); + const willExpenseReportBeEmpty = !!expenseReportID && reportHasOnlySplitChildren && !splitExpenses.some((expense) => expense?.reportID === expenseReportID); const willDeleteExpenseReport = isLastTransactionInReport || willExpenseReportBeEmpty; const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; From 10266e5ff58803198b703a4a1474d464ec33788e Mon Sep 17 00:00:00 2001 From: "Sobit Neupane (via MelvinBot)" Date: Sat, 23 May 2026 18:11:28 +0000 Subject: [PATCH 8/8] Guard against vacuously true every() when transactionsOnExpenseReport is empty Add length > 0 check to reportHasOnlySplitChildren so that an empty or partial allTransactionsList does not cause willExpenseReportBeEmpty to false-positive and incorrectly trigger delete-report navigation. Co-authored-by: Sobit Neupane --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 20deb1f8728c..1c6806b4f143 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1370,7 +1370,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const isLastTransactionInReport = isReverseSplitOperation && transactionsOnExpenseReport.length === 1; const originalChildIDs = new Set(originalChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); - const reportHasOnlySplitChildren = transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && originalChildIDs.has(tx.transactionID)); + const reportHasOnlySplitChildren = transactionsOnExpenseReport.length > 0 && transactionsOnExpenseReport.every((tx) => !!tx?.transactionID && originalChildIDs.has(tx.transactionID)); const willExpenseReportBeEmpty = !!expenseReportID && reportHasOnlySplitChildren && !splitExpenses.some((expense) => expense?.reportID === expenseReportID); const willDeleteExpenseReport = isLastTransactionInReport || willExpenseReportBeEmpty; const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID;