Skip to content
Open
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
33 changes: 7 additions & 26 deletions src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,8 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
const hash = queryJSON?.hash;

const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);

// The Search snapshot omits each transaction's `inserted` timestamp, so recency ordering must come from the
// local `transactions_` collection, which carries `inserted` for expenses the user has recently added.
const [localTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);

// Maps transactionID -> local `inserted` timestamp, used as the recency key the snapshot can't provide.
const insertedByTransactionID = useMemo(() => {
const map = new Map<string, string>();
for (const [key, transaction] of Object.entries(localTransactions ?? {})) {
const transactionID = transaction?.transactionID ?? key.slice(ONYXKEYS.COLLECTION.TRANSACTION.length);
if (transaction?.inserted) {
map.set(transactionID, transaction.inserted);
}
}
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(() => {
Expand Down Expand Up @@ -194,19 +179,15 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
// 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) ?? '';

// Order by the transaction's `inserted` timestamp (the immutable insertion time), most recent first.
return combined
.sort((firstTransaction, secondTransaction) => {
const firstKey = getRecencyKey(firstTransaction);
const secondKey = getRecencyKey(secondTransaction);
if (firstKey === secondKey) {
return 0;
const firstInserted = firstTransaction.inserted ?? '';
const secondInserted = secondTransaction.inserted ?? '';
if (firstInserted !== secondInserted) {
return firstInserted < secondInserted ? 1 : -1;
}
return firstKey < secondKey ? 1 : -1;
return firstTransaction.transactionID < secondTransaction.transactionID ? 1 : -1;
})
.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT)
.map((transaction) => {
Expand Down Expand Up @@ -238,7 +219,7 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
transaction: sourceTransaction,
};
});
}, [snapshotData, accountID, insertedByTransactionID, localPendingTransactions, localTransactionByID, translate]);
}, [snapshotData, accountID, localPendingTransactions, localTransactionByID, translate]);

return {transactions};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* not the on-demand `transactions_` Onyx collection
* - returns the current user's expenses, most recent first
* - sorts strictly by the `inserted` (creation/insertion) timestamp, never by `created` (expense date);
* falls back to `created` only when `inserted` is missing
* - caps the list at CONST.HOME.SECTION_VISIBLE_LIMIT (5) rows
* - includes expenses regardless of report status (no recency-window / draft-only filter)
* - defensively excludes expenses owned by another account when the snapshot carries the parent report
Expand Down Expand Up @@ -140,19 +139,21 @@ describe('useRecentlyAddedData — ordering', () => {
expect(resultTransactionIDs(result.current.transactions)).toEqual(['t3', 't2', 't1']);
});

it('falls back to the created date for ordering when an expense is missing its inserted timestamp', () => {
it('breaks ties between equal inserted timestamps deterministically by transactionID', () => {
setupSnapshot(
[
makeTransaction({transactionID: 'withCreatedOnly', created: '2026-06-05', inserted: undefined}),
makeTransaction({transactionID: 'withInserted', created: '2026-01-01', inserted: '2026-06-04 10:00:00'}),
makeTransaction({transactionID: 'aaa', created: '2026-06-01', inserted: '2026-06-05 10:00:00'}),
makeTransaction({transactionID: 'ccc', created: '2026-06-09', inserted: '2026-06-05 10:00:00'}),
makeTransaction({transactionID: 'bbb', created: '2026-06-05', inserted: '2026-06-05 10:00:00'}),
],
[makeReport('report_owned', ACCOUNT_ID)],
);

const {result} = renderHook(() => useRecentlyAddedData());

// withCreatedOnly has no inserted, so its created date (2026-06-05) is used and outranks withInserted (2026-06-04).
expect(resultTransactionIDs(result.current.transactions)).toEqual(['withCreatedOnly', 'withInserted']);
// Equal `inserted` timestamps tie-break on transactionID (descending) rather than the differing created dates,
// giving a stable order that never silently reshuffles across renders.
expect(resultTransactionIDs(result.current.transactions)).toEqual(['ccc', 'bbb', 'aaa']);
});

it('ranks an old-dated expense first when it was inserted most recently', () => {
Expand Down
Loading