Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/libs/TransactionThreadNavigationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';

import {createTransactionThreadReport} from './actions/Report';
import {getIOUActionForReportID} from './ReportActionsUtils';
import {getReportOrDraftReport} from './ReportUtils';
import {findSelfDMReportID, getReportOrDraftReport} from './ReportUtils';
import {isExpenseUnreported} from './TransactionUtils';

/**
Expand Down Expand Up @@ -44,10 +44,11 @@ function getReportIDToOpenForExpense(expense: TransactionThreadNavigationDescrip
const {transaction, reportID} = 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 expense.reportAction?.childReportID ?? reportID;
return expense.reportAction?.childReportID ?? getIOUActionForReportID(findSelfDMReportID(), transaction.transactionID)?.childReportID ?? reportID;
}

// Prefer the transaction thread resolved from the Search snapshot. The main reportActions_ collection
Expand Down
38 changes: 27 additions & 11 deletions src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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';
Expand Down Expand Up @@ -136,18 +137,33 @@ 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 (
<PressableWithFeedback
testID={`recentlyAddedRow-${expense.transactionID}`}
accessibilityLabel={expense.merchant}
sentryLabel="RecentlyAddedRow"
onPress={onPress}
wrapperStyle={styles.w100}
hoverStyle={styles.hoveredComponentBG}
style={[styles.flexRow, styles.alignItemsCenter, styles.gap3, styles.pv3, styles.ph3, styles.w100, shouldShowSeparator && styles.borderBottom]}
>
{({hovered}) => renderRowContent(hovered)}
</PressableWithFeedback>
<OfflineWithFeedback pendingAction={expense.pendingAction}>
<PressableWithFeedback
testID={`recentlyAddedRow-${expense.transactionID}`}
accessibilityLabel={expense.merchant}
sentryLabel="RecentlyAddedRow"
onPress={isPendingDelete ? () => {} : onPress}
wrapperStyle={styles.w100}
hoverStyle={styles.hoveredComponentBG}
style={[
styles.flexRow,
styles.alignItemsCenter,
styles.gap3,
styles.pv3,
styles.ph3,
styles.w100,
shouldShowSeparator && styles.borderBottom,
isPendingDelete && styles.cursorDefault,
]}
>
{({hovered}) => renderRowContent(hovered)}
</PressableWithFeedback>
</OfflineWithFeedback>
);
}

Expand Down
72 changes: 60 additions & 12 deletions src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ 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';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';

import {useIsFocused} from '@react-navigation/native';
import {useEffect, useEffectEvent, useMemo} from 'react';
Expand All @@ -35,6 +36,9 @@ type RecentlyAddedExpense = {
/** The expense currency */
currency: 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;

Expand All @@ -51,6 +55,15 @@ 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.
*
* 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();
Expand Down Expand Up @@ -87,6 +100,29 @@ 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<string, Transaction>();
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(
() =>
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;
Expand All @@ -112,10 +148,7 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
const snapshotData = searchResults?.data;

const transactions = useMemo(() => {
const data = snapshotData;
if (!data) {
return [];
}
const data = snapshotData ?? {};

const reportByReportID = new Map<string, Report>();
const snapshotTransactions: Transaction[] = [];
Expand Down Expand Up @@ -153,12 +186,20 @@ 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))]
// 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.
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);
Expand All @@ -169,6 +210,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 = reportByReportID.get(transaction.reportID)?.type;
const isFromExpenseReport = reportType === CONST.REPORT.TYPE.EXPENSE;
// Self-DM and unreported (tracked) expenses support signed amounts like expense reports, so their
Expand All @@ -179,18 +224,21 @@ 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),
reportAction: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID),
report: reportByReportID.get(transaction.reportID),
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, translate]);
}, [snapshotData, accountID, insertedByTransactionID, localPendingTransactions, localTransactionByID, translate]);

return {transactions};
}
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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<string, Transaction> = {};
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);
}
Expand Down Expand Up @@ -211,6 +220,110 @@ 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 — 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.
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(
Expand Down
Loading