From 6f25290b418107dc3b29f3dd51dfb865479ac950 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 22 Jun 2026 14:47:26 +0200 Subject: [PATCH 1/8] Show offline-created expenses in Recently added slot --- .../RecentlyAddedSection/RecentlyAddedRow.tsx | 25 +++++----- .../useRecentlyAddedData.ts | 33 ++++++++++--- .../useRecentlyAddedDataTest.ts | 48 +++++++++++++++++++ 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx index b3a1b898a0b2..81391eb7f1a5 100644 --- a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx +++ b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import ReceiptCell from '@components/TransactionItemRow/DataCells/ReceiptCell'; @@ -122,17 +123,19 @@ function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowRece ); return ( - - {({hovered}) => renderRowContent(hovered)} - + + + {({hovered}) => renderRowContent(hovered)} + + ); } diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index d03389cb6bcf..5c419ac5f3f1 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -10,6 +10,7 @@ import {getAmount, getCreated, getCurrency, getMerchant} from '@libs/Transaction import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; /** A single expense row surfaced by the Recently added slot. */ type RecentlyAddedExpense = { @@ -38,6 +39,9 @@ type RecentlyAddedExpense = { */ threadReportID?: string; + /** Pending action for locally-created expenses not yet synced, so the row can render the offline pending treatment */ + pendingAction?: PendingAction; + /** The full transaction, used to render the receipt thumbnail */ transaction: Transaction; }; @@ -48,6 +52,11 @@ type RecentlyAddedExpense = { * * Expenses come from the user's expense Search snapshot rather than the `transactions_` collection, which holds * only on-demand data and may be missing most expenses. + * + * The Search snapshot is only refreshed by an online API call, so a just-created expense (e.g. one added while + * offline) is absent from it until the next successful search. To keep the slot reflecting optimistic data, any + * locally-created expense still pending sync (`pendingAction === ADD`) is merged in and deduped against the + * snapshot by `transactionID`. This mirrors how other transaction lists surface offline-pending rows. */ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { const {accountID} = useCurrentUserPersonalDetails(); @@ -83,6 +92,16 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { return map; }, [localTransactions]); + // Expenses created locally but not yet synced (e.g. added while offline) are absent from the snapshot, so they're + // merged in below. A local optimistic ADD always belongs to the current user, so a `reportID` is the only requirement. + const localPendingTransactions = useMemo( + () => + Object.values(localTransactions ?? {}).filter( + (transaction): transaction is Transaction & {reportID: string} => transaction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !!transaction?.reportID, + ), + [localTransactions], + ); + const fireSearch = useEffectEvent(() => { if (isOffline || !queryJSON) { return; @@ -108,10 +127,7 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { const snapshotData = searchResults?.data; const transactions = useMemo(() => { - const data = snapshotData; - if (!data) { - return []; - } + const data = snapshotData ?? {}; const reportOwnerByReportID = new Map(); const snapshotTransactions: Transaction[] = []; @@ -149,12 +165,16 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { return ownerAccountID === undefined || ownerAccountID === accountID; }); + // Merge in locally-pending expenses, skipping any already in the snapshot so a row never appears twice. + const snapshotTransactionIDs = new Set(snapshotTransactions.map((transaction) => transaction.transactionID)); + const combined = [...filtered, ...localPendingTransactions.filter((transaction) => !snapshotTransactionIDs.has(transaction.transactionID))]; + // Recency key: prefer the local `inserted` timestamp (full precision, present for recently added expenses), // then any snapshot `inserted`, then fall back to the expense date. Newest first. const getRecencyKey = (transaction: Transaction & {reportID: string}) => insertedByTransactionID.get(transaction.transactionID) ?? transaction.inserted ?? getCreated(transaction) ?? ''; - return filtered + return combined .sort((firstTransaction, secondTransaction) => { const firstKey = getRecencyKey(firstTransaction); const secondKey = getRecencyKey(secondTransaction); @@ -172,9 +192,10 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { amount: getAmount(transaction), currency: getCurrency(transaction), threadReportID: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID)?.childReportID, + pendingAction: transaction.pendingAction, transaction, })); - }, [snapshotData, accountID, insertedByTransactionID]); + }, [snapshotData, accountID, insertedByTransactionID, localPendingTransactions]); return {transactions}; } diff --git a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts index 8ceb385ca91a..1b58b203d524 100644 --- a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts +++ b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts @@ -98,6 +98,15 @@ function setupSnapshot(transactions: Transaction[], reports: Report[]) { onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${SNAPSHOT_HASH}`] = {data}; } +/** Seeds the local `transactions_` collection (mirrors what optimistic expense creation writes to Onyx). */ +function setupLocalTransactions(transactions: Transaction[]) { + const collection: Record = {}; + for (const transaction of transactions) { + collection[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + } + onyxData[ONYXKEYS.COLLECTION.TRANSACTION] = collection; +} + function resultTransactionIDs(transactions: Transaction[]): string[] { return transactions.map((t) => t.transactionID); } @@ -208,6 +217,45 @@ describe('useRecentlyAddedData — unreported expenses', () => { }); }); +describe('useRecentlyAddedData — locally pending (offline-created) expenses', () => { + it('surfaces a locally-pending expense that has not yet reached the snapshot', () => { + setupSnapshot([makeTransaction({transactionID: 'synced', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([makeTransaction({transactionID: 'pending', inserted: '2026-06-02 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + // The pending expense is newer, so it ranks ahead of the synced one. + expect(resultTransactionIDs(result.current.transactions)).toEqual(['pending', 'synced']); + }); + + it('exposes the pending action so the row can render the offline pending treatment', () => { + setupSnapshot([], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([makeTransaction({transactionID: 'pending', inserted: '2026-06-02 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(result.current.transactions.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + }); + + it('does not duplicate an expense present in both the snapshot and the local collection', () => { + setupSnapshot([makeTransaction({transactionID: 'shared', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([makeTransaction({transactionID: 'shared', inserted: '2026-06-01 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(resultTransactionIDs(result.current.transactions)).toEqual(['shared']); + }); + + it('ignores local transactions that are not pending creation (on-demand data is not a source of expenses)', () => { + setupSnapshot([makeTransaction({transactionID: 'synced', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([makeTransaction({transactionID: 'onDemand', inserted: '2026-06-09 10:00:00'})]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(resultTransactionIDs(result.current.transactions)).toEqual(['synced']); + }); +}); + describe('useRecentlyAddedData — empty snapshot', () => { it('returns no expenses when the snapshot has not loaded yet', () => { delete onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${SNAPSHOT_HASH}`]; From c4df0b2817129ea033d9b8f15d51e70123b6c6e2 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 22 Jun 2026 16:20:20 +0200 Subject: [PATCH 2/8] Resolve pending unreported expense thread for offline tracked expenses An offline-created personal tracked expense is absent from the Search snapshot, so its transaction thread couldn't be resolved and the Recently added row navigated to report "0". Fall back to local report actions to find the IOU action's childReportID when the snapshot has no thread yet. --- src/libs/ReportActionsUtils.ts | 19 +++++++++++++++++++ src/libs/TransactionThreadNavigationUtils.ts | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c727fdf9a2aa..1785d646b1ae 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2639,6 +2639,24 @@ function getIOUActionForTransactionID(reportActions: ReportAction[], transaction }); } +/** + * Finds the transaction thread (the IOU action's `childReportID`) for a transaction by scanning all locally stored + * report actions. Useful for optimistic, not-yet-synced expenses (e.g. an offline personal tracked expense) whose IOU + * action lives in the self-DM but isn't yet present in the Search snapshot. + */ +function getThreadReportIDForTransaction(transactionID: string | undefined): string | undefined { + if (!transactionID) { + return undefined; + } + for (const reportActions of Object.values(allReportActions ?? {})) { + const childReportID = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID; + if (childReportID) { + return childReportID; + } + } + return undefined; +} + /** * Get the track expense actionable whisper of the corresponding track expense */ @@ -4754,6 +4772,7 @@ export { getNumberOfMoneyRequests, getOneTransactionThreadReportAction, getOneTransactionThreadReportID, + getThreadReportIDForTransaction, getOriginalMessage, getAddedApprovalRuleMessage, getDeletedApprovalRuleMessage, diff --git a/src/libs/TransactionThreadNavigationUtils.ts b/src/libs/TransactionThreadNavigationUtils.ts index 3a342bd778d8..142324f612d2 100644 --- a/src/libs/TransactionThreadNavigationUtils.ts +++ b/src/libs/TransactionThreadNavigationUtils.ts @@ -1,7 +1,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; import {createTransactionThreadReport} from './actions/Report'; -import {getIOUActionForReportID} from './ReportActionsUtils'; +import {getIOUActionForReportID, getThreadReportIDForTransaction} from './ReportActionsUtils'; import {getReportOrDraftReport, isOneTransactionReport} from './ReportUtils'; import {isExpenseUnreported} from './TransactionUtils'; @@ -41,10 +41,11 @@ function getReportIDToOpenForExpense(expense: TransactionThreadNavigationDescrip const {transaction, reportID, threadReportID} = expense; const isUnreported = isExpenseUnreported(transaction); - // Unreported (tracked) expenses live in the self-DM; their transaction thread (resolved from the - // snapshot) is the expense view to open, since report "0" does not exist. + // Unreported (tracked) expenses live in the self-DM; their transaction thread is the expense view to open, since + // report "0" does not exist. Prefer the snapshot-resolved thread, but fall back to local report actions so an + // optimistic (offline) expense — absent from the snapshot — still resolves to its real thread. if (isUnreported) { - return threadReportID ?? reportID; + return threadReportID ?? getThreadReportIDForTransaction(transaction.transactionID) ?? reportID; } const parentReport = getReportOrDraftReport(reportID); From c0c5aaccda58faff4ccada7fca394f07b52b1a9f Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 30 Jun 2026 16:15:59 +0200 Subject: [PATCH 3/8] Prevent navigation from pending-delete recently added expense rows A deleted expense in the Recently added section stayed clickable while offline since OfflineWithFeedback only strikes the row through. Make the row's onPress a no-op and show the default cursor when the expense is pending deletion. --- .../RecentlyAddedSection/RecentlyAddedRow.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx index c3a42d55132d..88fa5acb3101 100644 --- a/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx +++ b/src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx @@ -131,16 +131,29 @@ function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowRece ); + // A pending-delete expense is on its way out, so its row must not navigate anywhere (offline it stays + // visible with strikethrough; online OfflineWithFeedback hides it entirely). + const isPendingDelete = expense.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return ( {} : onPress} wrapperStyle={styles.w100} hoverStyle={styles.hoveredComponentBG} - style={[styles.flexRow, styles.alignItemsCenter, styles.gap3, styles.pv3, styles.ph3, styles.w100, shouldShowSeparator && styles.borderBottom]} + style={[ + styles.flexRow, + styles.alignItemsCenter, + styles.gap3, + styles.pv3, + styles.ph3, + styles.w100, + shouldShowSeparator && styles.borderBottom, + isPendingDelete && styles.cursorDefault, + ]} > {({hovered}) => renderRowContent(hovered)} From 05c14d020e10049c1974d653d4a6f9e8138e29ca Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 30 Jun 2026 16:44:33 +0200 Subject: [PATCH 4/8] Surface offline pending state for edited recently added expenses Prefer the local transactions_ copy so an offline edit's updated values and pendingFields drive the row, rendering the offline pending treatment for edits (UPDATE) alongside creates (ADD) and deletes (DELETE). --- .../useRecentlyAddedData.ts | 39 +++++++++++++++---- .../useRecentlyAddedDataTest.ts | 32 +++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index 9b446ac84022..422cf3018918 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -7,7 +7,7 @@ import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {buildQueryStringFromFilterFormValues, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import {getAmount, getCreated, getCurrency, getMerchantName} from '@libs/TransactionUtils'; +import {getAmount, getCreated, getCurrency, getMerchantName, getTransactionPendingAction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, Transaction} from '@src/types/onyx'; @@ -58,6 +58,10 @@ type RecentlyAddedExpense = { * offline) is absent from it until the next successful search. To keep the slot reflecting optimistic data, any * locally-created expense still pending sync (`pendingAction === ADD`) is merged in and deduped against the * snapshot by `transactionID`. This mirrors how other transaction lists surface offline-pending rows. + * + * Offline edits and deletes mutate only the local `transactions_` copy, never the snapshot, so each row prefers + * its local copy when present. That keeps the displayed values fresh and lets the row render the offline pending + * treatment for edits (`pendingFields` -> UPDATE) and deletes (DELETE), not just creates. */ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { const {accountID} = useCurrentUserPersonalDetails(); @@ -94,6 +98,19 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { return map; }, [localTransactions]); + // Maps transactionID -> local `transactions_` copy. When present it carries the freshest optimistic state + // (edited values and `pendingFields`) for an offline edit, which the server-backed snapshot doesn't yet reflect. + const localTransactionByID = useMemo(() => { + const map = new Map(); + for (const [key, transaction] of Object.entries(localTransactions ?? {})) { + if (!transaction) { + continue; + } + map.set(transaction.transactionID ?? key.slice(ONYXKEYS.COLLECTION.TRANSACTION.length), transaction); + } + return map; + }, [localTransactions]); + // Expenses created locally but not yet synced (e.g. added while offline) are absent from the snapshot, so they're // merged in below. A local optimistic ADD always belongs to the current user, so a `reportID` is the only requirement. const localPendingTransactions = useMemo( @@ -191,6 +208,10 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { }) .slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT) .map((transaction) => { + // An offline edit only mutates the local `transactions_` copy (updated values + `pendingFields`); the + // snapshot keeps the stale, pre-edit copy. Prefer the local copy when present so the row reflects the + // edit and can render the offline pending treatment, matching how the Search transaction list behaves. + const sourceTransaction = localTransactionByID.get(transaction.transactionID) ?? transaction; const reportType = reportTypeByReportID.get(transaction.reportID); const isFromExpenseReport = reportType === CONST.REPORT.TYPE.EXPENSE; // Self-DM and unreported (tracked) expenses support signed amounts like expense reports, so their @@ -201,18 +222,20 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { return { transactionID: transaction.transactionID, reportID: transaction.reportID, - created: getCreated(transaction), - merchant: getMerchantName(transaction, translate), + created: getCreated(sourceTransaction), + merchant: getMerchantName(sourceTransaction, translate), // Expense-report, self-DM, and tracked transactions are stored with an inverted sign, so the // displayed amount must be negated for them (mirrors the Search transaction list). - amount: getAmount(transaction, isFromExpenseReport, isFromTrackedExpense), - currency: getCurrency(transaction), + amount: getAmount(sourceTransaction, isFromExpenseReport, isFromTrackedExpense), + currency: getCurrency(sourceTransaction), threadReportID: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID)?.childReportID, - pendingAction: transaction.pendingAction, - transaction, + // Derive from the local copy so an offline edit (which sets `pendingFields`, not `pendingAction`) + // still surfaces the pending state, alongside offline creates (ADD) and deletes (DELETE). + pendingAction: getTransactionPendingAction(sourceTransaction), + transaction: sourceTransaction, }; }); - }, [snapshotData, accountID, insertedByTransactionID, localPendingTransactions, translate]); + }, [snapshotData, accountID, insertedByTransactionID, localPendingTransactions, localTransactionByID, translate]); return {transactions}; } diff --git a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts index b77930ece0e4..4cd724455f8e 100644 --- a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts +++ b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts @@ -256,6 +256,38 @@ describe('useRecentlyAddedData — locally pending (offline-created) expenses', }); }); +describe('useRecentlyAddedData — offline-edited expenses', () => { + it('surfaces the pending action for an expense edited offline, derived from its local pendingFields', () => { + // The snapshot keeps the stale, pre-edit copy; the offline edit lives only on the local `transactions_` copy. + setupSnapshot([makeTransaction({transactionID: 'edited', amount: 1000, merchant: 'Old Merchant', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([ + makeTransaction({ + transactionID: 'edited', + amount: 2500, + merchant: 'New Merchant', + inserted: '2026-06-01 10:00:00', + pendingFields: {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }), + ]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + // A single row (no duplicate), reflecting the optimistic edit and flagged as a pending UPDATE. + expect(resultTransactionIDs(result.current.transactions)).toEqual(['edited']); + expect(result.current.transactions.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(result.current.transactions.at(0)?.amount).toBe(2500); + expect(result.current.transactions.at(0)?.merchant).toBe('New Merchant'); + }); + + it('leaves a fully-synced expense without a pending action', () => { + setupSnapshot([makeTransaction({transactionID: 'synced', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(result.current.transactions.at(0)?.pendingAction).toBeNull(); + }); +}); + describe('useRecentlyAddedData — amount sign', () => { it('preserves the negative sign for self-DM credits/refunds', () => { setupSnapshot( From 7df9f07cdaa914660ebe520b841a1eea10b52aeb Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 30 Jun 2026 17:56:38 +0200 Subject: [PATCH 5/8] Hide split-parent expense from Recently added slot after splitting Splitting reassigns the original transaction to the synthetic SPLIT_REPORT_ID and adds the split children as new expenses, but the slot still surfaced the orphaned original alongside the splits. Filter out transactions whose effective reportID is SPLIT_REPORT_ID, preferring the local copy so it works before the snapshot refreshes (offline splits). --- .../useRecentlyAddedData.ts | 6 +++- .../useRecentlyAddedDataTest.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index 422cf3018918..504ad3ace601 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -190,7 +190,11 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} { // Merge in locally-pending expenses, skipping any already in the snapshot so a row never appears twice. const snapshotTransactionIDs = new Set(snapshotTransactions.map((transaction) => transaction.transactionID)); - const combined = [...filtered, ...localPendingTransactions.filter((transaction) => !snapshotTransactionIDs.has(transaction.transactionID))]; + const combined = [...filtered, ...localPendingTransactions.filter((transaction) => !snapshotTransactionIDs.has(transaction.transactionID))] + // When an expense is split, its (local) copy is reassigned to the synthetic SPLIT_REPORT_ID and the + // resulting split children are added as new expenses. Drop the now-orphaned original so the slot shows + // only the splits. Prefer the local copy's reportID, which reflects the split even before the snapshot refreshes. + .filter((transaction) => (localTransactionByID.get(transaction.transactionID)?.reportID ?? transaction.reportID) !== CONST.REPORT.SPLIT_REPORT_ID); // Recency key: prefer the local `inserted` timestamp (full precision, present for recently added expenses), // then any snapshot `inserted`, then fall back to the expense date. Newest first. diff --git a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts index 4cd724455f8e..27a5f3688c75 100644 --- a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts +++ b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts @@ -256,6 +256,39 @@ describe('useRecentlyAddedData — locally pending (offline-created) expenses', }); }); +describe('useRecentlyAddedData — split expenses', () => { + it('drops the original expense once it is split, keeping only the resulting splits', () => { + // Splitting reassigns the original transaction to the synthetic SPLIT_REPORT_ID and adds the split children + // as new pending expenses. The snapshot still carries the (now reassigned) original. + setupSnapshot( + [ + makeTransaction({transactionID: 'splitParent', reportID: CONST.REPORT.SPLIT_REPORT_ID, inserted: '2026-06-01 10:00:00'}), + makeTransaction({transactionID: 'unrelated', reportID: 'report_owned', inserted: '2026-06-02 10:00:00'}), + ], + [makeReport('report_owned', ACCOUNT_ID)], + ); + setupLocalTransactions([ + makeTransaction({transactionID: 'splitParent', reportID: CONST.REPORT.SPLIT_REPORT_ID, inserted: '2026-06-01 10:00:00'}), + makeTransaction({transactionID: 'splitChild1', reportID: 'report_owned', inserted: '2026-06-03 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}), + makeTransaction({transactionID: 'splitChild2', reportID: 'report_owned', inserted: '2026-06-04 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}), + ]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(resultTransactionIDs(result.current.transactions)).toEqual(['splitChild2', 'splitChild1', 'unrelated']); + }); + + it('drops the split-parent when only its local copy has been reassigned (snapshot not yet refreshed)', () => { + // Offline split: the snapshot still holds the original reportID, but the local copy is already reassigned. + setupSnapshot([makeTransaction({transactionID: 'splitParent', reportID: 'report_owned', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalTransactions([makeTransaction({transactionID: 'splitParent', reportID: CONST.REPORT.SPLIT_REPORT_ID, inserted: '2026-06-01 10:00:00'})]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(resultTransactionIDs(result.current.transactions)).toEqual([]); + }); +}); + describe('useRecentlyAddedData — offline-edited expenses', () => { it('surfaces the pending action for an expense edited offline, derived from its local pendingFields', () => { // The snapshot keeps the stale, pre-edit copy; the offline edit lives only on the local `transactions_` copy. From 077f68723d3ad4788ec3464e7a31dbe107a001bc Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 30 Jun 2026 17:59:36 +0200 Subject: [PATCH 6/8] Add tests for getThreadReportIDForTransaction --- tests/unit/ReportActionsUtilsTest.ts | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 9bdae0749c5b..809c6c5601e0 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -602,6 +602,59 @@ describe('ReportActionsUtils', () => { }); }); + describe('getThreadReportIDForTransaction', () => { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + const originalMessage = getOriginalMessage(mockIOUAction) as OriginalMessageIOU; + + const buildIOUAction = (transactionID: string, childReportID: string | undefined): ReportAction => ({ + ...mockIOUAction, + originalMessage: {...originalMessage, IOUTransactionID: transactionID}, + childReportID, + }); + + it('returns undefined when transactionID is undefined', () => { + expect(ReportActionsUtils.getThreadReportIDForTransaction(undefined)).toBeUndefined(); + }); + + it('returns the childReportID of the IOU action matching the transaction', async () => { + const action = buildIOUAction('transaction1', 'thread1'); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}selfDM`]: {[action.reportActionID]: action}, + } as unknown as KeyValueMapping); + + expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBe('thread1'); + }); + + it('scans across all reports to find the matching transaction thread', async () => { + const otherAction = buildIOUAction('otherTransaction', 'otherThread'); + const targetAction = buildIOUAction('targetTransaction', 'targetThread'); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[otherAction.reportActionID]: otherAction}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report2`]: {[targetAction.reportActionID]: targetAction}, + } as unknown as KeyValueMapping); + + expect(ReportActionsUtils.getThreadReportIDForTransaction('targetTransaction')).toBe('targetThread'); + }); + + it('returns undefined when no action matches the transaction', async () => { + const action = buildIOUAction('transaction1', 'thread1'); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[action.reportActionID]: action}, + } as unknown as KeyValueMapping); + + expect(ReportActionsUtils.getThreadReportIDForTransaction('unknownTransaction')).toBeUndefined(); + }); + + it('returns undefined when the matching IOU action has no childReportID', async () => { + const action = buildIOUAction('transaction1', undefined); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[action.reportActionID]: action}, + } as unknown as KeyValueMapping); + + expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBeUndefined(); + }); + }); + describe('getSortedReportActionsForDisplay', () => { it('should filter out non-whitelisted actions', () => { const input: ReportAction[] = [ From 86340bd93c11451285b3dc0d17315a1f7b058929 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 1 Jul 2026 18:00:16 +0200 Subject: [PATCH 7/8] Fix eslint-seatbelt no-unsafe-type-assertion overflow in ReportActionsUtilsTest --- tests/unit/ReportActionsUtilsTest.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index cff7a5a2c254..89f9c76c0f8c 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -603,12 +603,9 @@ describe('ReportActionsUtils', () => { }); describe('getThreadReportIDForTransaction', () => { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const originalMessage = getOriginalMessage(mockIOUAction) as OriginalMessageIOU; - const buildIOUAction = (transactionID: string, childReportID: string | undefined): ReportAction => ({ ...mockIOUAction, - originalMessage: {...originalMessage, IOUTransactionID: transactionID}, + originalMessage: {...mockOriginalMessage, IOUTransactionID: transactionID}, childReportID, }); @@ -618,9 +615,7 @@ describe('ReportActionsUtils', () => { it('returns the childReportID of the IOU action matching the transaction', async () => { const action = buildIOUAction('transaction1', 'thread1'); - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}selfDM`]: {[action.reportActionID]: action}, - } as unknown as KeyValueMapping); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}selfDM`, {[action.reportActionID]: action}); expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBe('thread1'); }); @@ -628,28 +623,22 @@ describe('ReportActionsUtils', () => { it('scans across all reports to find the matching transaction thread', async () => { const otherAction = buildIOUAction('otherTransaction', 'otherThread'); const targetAction = buildIOUAction('targetTransaction', 'targetThread'); - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[otherAction.reportActionID]: otherAction}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report2`]: {[targetAction.reportActionID]: targetAction}, - } as unknown as KeyValueMapping); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[otherAction.reportActionID]: otherAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report2`, {[targetAction.reportActionID]: targetAction}); expect(ReportActionsUtils.getThreadReportIDForTransaction('targetTransaction')).toBe('targetThread'); }); it('returns undefined when no action matches the transaction', async () => { const action = buildIOUAction('transaction1', 'thread1'); - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[action.reportActionID]: action}, - } as unknown as KeyValueMapping); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[action.reportActionID]: action}); expect(ReportActionsUtils.getThreadReportIDForTransaction('unknownTransaction')).toBeUndefined(); }); it('returns undefined when the matching IOU action has no childReportID', async () => { const action = buildIOUAction('transaction1', undefined); - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`]: {[action.reportActionID]: action}, - } as unknown as KeyValueMapping); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[action.reportActionID]: action}); expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBeUndefined(); }); From 1fd47cb56c8d4b006bdf645b406452c674cdf5dc Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 6 Jul 2026 12:26:43 +0200 Subject: [PATCH 8/8] Resolve unreported thread ID via self-DM lookup instead of scanning all report actions Addresses PR review comment: reuse getIOUActionForReportID(findSelfDMReportID(), ...) and remove the getThreadReportIDForTransaction helper and its tests. --- src/libs/ReportActionsUtils.ts | 19 --------- src/libs/TransactionThreadNavigationUtils.ts | 6 +-- tests/unit/ReportActionsUtilsTest.ts | 42 -------------------- 3 files changed, 3 insertions(+), 64 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 57f525b7ac3f..bf2488490a50 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2650,24 +2650,6 @@ function getIOUActionForTransactionID(reportActions: ReportAction[], transaction }); } -/** - * Finds the transaction thread (the IOU action's `childReportID`) for a transaction by scanning all locally stored - * report actions. Useful for optimistic, not-yet-synced expenses (e.g. an offline personal tracked expense) whose IOU - * action lives in the self-DM but isn't yet present in the Search snapshot. - */ -function getThreadReportIDForTransaction(transactionID: string | undefined): string | undefined { - if (!transactionID) { - return undefined; - } - for (const reportActions of Object.values(allReportActions ?? {})) { - const childReportID = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID; - if (childReportID) { - return childReportID; - } - } - return undefined; -} - /** * Get the track expense actionable whisper of the corresponding track expense */ @@ -4809,7 +4791,6 @@ export { getNumberOfMoneyRequests, getOneTransactionThreadReportAction, getOneTransactionThreadReportID, - getThreadReportIDForTransaction, getOriginalMessage, getAddedApprovalRuleMessage, getDeletedApprovalRuleMessage, diff --git a/src/libs/TransactionThreadNavigationUtils.ts b/src/libs/TransactionThreadNavigationUtils.ts index 32577febfbfc..3c195287cc86 100644 --- a/src/libs/TransactionThreadNavigationUtils.ts +++ b/src/libs/TransactionThreadNavigationUtils.ts @@ -3,8 +3,8 @@ import type {Beta, IntroSelected, Report, ReportAction, Transaction} from '@src/ import type {OnyxEntry} from 'react-native-onyx'; import {createTransactionThreadReport} from './actions/Report'; -import {getIOUActionForReportID, getThreadReportIDForTransaction} from './ReportActionsUtils'; -import {getReportOrDraftReport} from './ReportUtils'; +import {getIOUActionForReportID} from './ReportActionsUtils'; +import {findSelfDMReportID, getReportOrDraftReport} from './ReportUtils'; import {isExpenseUnreported} from './TransactionUtils'; /** @@ -48,7 +48,7 @@ function getReportIDToOpenForExpense(expense: TransactionThreadNavigationDescrip // since report "0" does not exist. Prefer the snapshot-resolved thread, but fall back to local report actions // so an optimistic (offline) expense — absent from the snapshot — still resolves to its real thread. if (isUnreported) { - return expense.reportAction?.childReportID ?? getThreadReportIDForTransaction(transaction.transactionID) ?? reportID; + return expense.reportAction?.childReportID ?? getIOUActionForReportID(findSelfDMReportID(), transaction.transactionID)?.childReportID ?? reportID; } // Prefer the transaction thread resolved from the Search snapshot. The main reportActions_ collection diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 15418ec74d16..a7f0dd1feffd 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -656,48 +656,6 @@ describe('ReportActionsUtils', () => { }); }); - describe('getThreadReportIDForTransaction', () => { - const buildIOUAction = (transactionID: string, childReportID: string | undefined): ReportAction => ({ - ...mockIOUAction, - originalMessage: {...mockOriginalMessage, IOUTransactionID: transactionID}, - childReportID, - }); - - it('returns undefined when transactionID is undefined', () => { - expect(ReportActionsUtils.getThreadReportIDForTransaction(undefined)).toBeUndefined(); - }); - - it('returns the childReportID of the IOU action matching the transaction', async () => { - const action = buildIOUAction('transaction1', 'thread1'); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}selfDM`, {[action.reportActionID]: action}); - - expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBe('thread1'); - }); - - it('scans across all reports to find the matching transaction thread', async () => { - const otherAction = buildIOUAction('otherTransaction', 'otherThread'); - const targetAction = buildIOUAction('targetTransaction', 'targetThread'); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[otherAction.reportActionID]: otherAction}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report2`, {[targetAction.reportActionID]: targetAction}); - - expect(ReportActionsUtils.getThreadReportIDForTransaction('targetTransaction')).toBe('targetThread'); - }); - - it('returns undefined when no action matches the transaction', async () => { - const action = buildIOUAction('transaction1', 'thread1'); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[action.reportActionID]: action}); - - expect(ReportActionsUtils.getThreadReportIDForTransaction('unknownTransaction')).toBeUndefined(); - }); - - it('returns undefined when the matching IOU action has no childReportID', async () => { - const action = buildIOUAction('transaction1', undefined); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report1`, {[action.reportActionID]: action}); - - expect(ReportActionsUtils.getThreadReportIDForTransaction('transaction1')).toBeUndefined(); - }); - }); - describe('getSortedReportActionsForDisplay', () => { it('should filter out non-whitelisted actions', () => { const input: ReportAction[] = [