Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4eaeec2
fix: add optimistic group_ entry for grouped-by-from search when crea…
aswin-s May 30, 2026
855cac9
fix: read live Onyx snapshot to correctly accumulate group count/total
aswin-s May 30, 2026
2daacd9
fix: resolve typecheck and eslint failures in SearchUpdate
aswin-s Jun 2, 2026
505e32a
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 2, 2026
fc7cb8d
refactor: move SNAPSHOT subscription into IOU/index.ts
aswin-s Jun 2, 2026
e3c3dc8
fix: allow optimistic search update when only filter is from:<current…
aswin-s Jun 2, 2026
331791b
fix: fan optimistic search update to all loaded snapshots matching th…
aswin-s Jun 2, 2026
d158515
fix: evict searchQueryByHash entries when their snapshot disappears
aswin-s Jun 2, 2026
bf89407
fix: rename drilldown to groupTransactionsQuery to satisfy spellcheck
aswin-s Jun 2, 2026
4ac2246
polish: address inline review nits
aswin-s Jun 2, 2026
9da8fb5
chore: bump eslint-seatbelt baseline for new Onyx.connect in IOU/inde…
aswin-s Jun 2, 2026
6c8b543
test: cover the early-return guards in getSearchOnyxUpdate
aswin-s Jun 2, 2026
5cb7124
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 7, 2026
ed0e288
Prune stale SEARCH_QUERY_BY_HASH entries on first snapshot sync
aswin-s Jun 7, 2026
8353d2f
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 9, 2026
416a8c0
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 11, 2026
f3aa698
Remove unnecessary type assertion in SearchUpdate
aswin-s Jun 16, 2026
8c3ebe5
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 16, 2026
828e297
Merge remote-tracking branch 'origin/main' into fix/issue-91453
aswin-s Jun 24, 2026
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
2 changes: 1 addition & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@
"../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "no-restricted-syntax" 1
"../../src/libs/actions/IOU/index.ts" "@typescript-eslint/no-deprecated/getMoneyRequestPolicyTags" 1
"../../src/libs/actions/IOU/index.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 2
"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 10
"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 12
"../../src/libs/actions/ImportTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/actions/ImportTransactions.ts" "no-restricted-syntax" 1
"../../src/libs/actions/InputFocus/index.web.ts" "no-restricted-syntax" 1
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,9 @@ const ONYXKEYS = {
/** Stores the current search page context (e.g., whether to show the search query) */
SEARCH_CONTEXT: 'searchContext',

/** Maps each loaded search snapshot's hash to its original query string, used to fan optimistic IOU updates to every matching snapshot */
SEARCH_QUERY_BY_HASH: 'searchQueryByHash',

/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',

Expand Down Expand Up @@ -1427,6 +1430,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.RECENT_SEARCHES]: Record<string, OnyxTypes.RecentSearchItem>;
[ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch;
[ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext;
[ONYXKEYS.SEARCH_QUERY_BY_HASH]: Record<string, string>;
[ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[];
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
Expand Down
187 changes: 120 additions & 67 deletions src/libs/actions/IOU/SearchUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type * as OnyxTypes from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type {OnyxData} from '@src/types/onyx/Request';
import type {SearchResultDataType} from '@src/types/onyx/SearchResults';
import {getCurrentUserPersonalDetails} from './index';
import {getAllSnapshots, getCurrentUserPersonalDetails, getSearchQueryByHash} from './index';

type ExpenseReportStatusPredicate = (expenseReport: OnyxEntry<OnyxTypes.Report>, transactionReportID?: string) => boolean;

Expand Down Expand Up @@ -90,6 +90,11 @@ function shouldOptimisticallyUpdateSearch(

const hasNoFlatFilters = currentSearchQueryJSON.flatFilters.length === 0;

const onlyFromFilter = currentSearchQueryJSON.flatFilters.length === 1 ? currentSearchQueryJSON.flatFilters.at(0) : undefined;
const matchesFromQuery: boolean =
onlyFromFilter?.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM &&
onlyFromFilter.filters.some((f) => f.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && String(f.value) === String(currentUserAccountID));

const matchesSubmitQuery =
submitQueryJSON?.similarSearchHash === currentSearchQueryJSON.similarSearchHash && expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.DRAFTS](iouReport);

Expand All @@ -102,7 +107,7 @@ function shouldOptimisticallyUpdateSearch(
(expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.DRAFTS](iouReport) || expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING](iouReport)) &&
transaction?.reimbursable;

const matchesFilterQuery = hasNoFlatFilters || matchesSubmitQuery || matchesApproveQuery || matchesUnapprovedCashQuery;
const matchesFilterQuery = hasNoFlatFilters || matchesFromQuery || matchesSubmitQuery || matchesApproveQuery || matchesUnapprovedCashQuery;

return shouldOptimisticallyUpdateByStatus && validSearchTypes && matchesFilterQuery;
}
Expand All @@ -120,23 +125,91 @@ function getSearchOnyxUpdate({
const toAccountID = participant?.accountID;
const deprecatedCurrentUserPersonalDetails = getCurrentUserPersonalDetails();
const fromAccountID = deprecatedCurrentUserPersonalDetails?.accountID;
const currentSearchQueryJSON = getCurrentSearchQueryJSON();

if (!currentSearchQueryJSON || toAccountID === undefined || fromAccountID === undefined) {
if (toAccountID === undefined || fromAccountID === undefined) {
return;
}

if (shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, isInvoice, fromAccountID, transaction)) {
const isOptimisticToAccountData = isOptimisticPersonalDetail(toAccountID);
const successData = [];
// Common transaction payload merged into every matching snapshot.
const baseSnapshotData: SearchResultDataType = {};
baseSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = {
[toAccountID]: {
accountID: toAccountID,
displayName: participant?.displayName,
login: participant?.login,
},
[fromAccountID]: {
accountID: fromAccountID,
avatar: deprecatedCurrentUserPersonalDetails?.avatar,
displayName: deprecatedCurrentUserPersonalDetails?.displayName,
login: deprecatedCurrentUserPersonalDetails?.login,
},
};
baseSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = {
...(transactionThreadReportID && {transactionThreadReportID}),
...(isFromOneTransactionReport && {isFromOneTransactionReport}),
...transaction,
};
if (policy) {
baseSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy;
}
if (iouReport) {
baseSnapshotData[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`] = iouReport;
}
if (iouReport && iouAction) {
baseSnapshotData[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`] = {[iouAction.reportActionID]: iouAction};
}

const isOptimisticToAccountData = isOptimisticPersonalDetail(toAccountID);
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [];
const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [];
const writtenHashes = new Set<number>();
const allSnapshots = getAllSnapshots() ?? {};

const writeForQuery = (queryJSON: Readonly<SearchQueryJSON>, existingSnapshot: OnyxEntry<OnyxTypes.SearchResults>) => {
if (writtenHashes.has(queryJSON.hash)) {
return;
}
if (!shouldOptimisticallyUpdateSearch(queryJSON, iouReport, isInvoice, fromAccountID, transaction)) {
return;
}

const snapshotData: SearchResultDataType = {...baseSnapshotData};

if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) {
const groupKey = `${CONST.SEARCH.GROUP_PREFIX}${fromAccountID}` as const;
const existingGroup = existingSnapshot?.data?.[groupKey];
snapshotData[groupKey] = {
accountID: fromAccountID,
count: (existingGroup?.count ?? 0) + 1,
total: (existingGroup?.total ?? 0) + (transaction.amount ?? 0),
currency: existingGroup?.currency ?? transaction.currency ?? CONST.CURRENCY.USD,
};
}

optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const,
value: {
search: {
type: queryJSON.type,
status: queryJSON.status,
hasResults: true,
isLoading: false,
},
data: snapshotData,
},
});
writtenHashes.add(queryJSON.hash);

if (isOptimisticToAccountData) {
// The optimistic personal detail is cleared from PERSONAL_DETAILS_LIST on API success, but the snapshot's report still references
// that optimistic accountID via report.managerID. Re-merging the personal detail into the snapshot in successData prevents the
// "To" column from briefly going blank before Search API delivers the real data.
// See https://github.com/Expensify/App/issues/61310 for more information.
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}` as const,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const,
value: {
data: {
[ONYXKEYS.PERSONAL_DETAILS_LIST]: {
Expand All @@ -150,73 +223,25 @@ function getSearchOnyxUpdate({
},
});
}
// Building this object sequentially resolves TypeScript type inference issues
const optimisticSnapshotData: SearchResultDataType = {};

optimisticSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = {
[toAccountID]: {
accountID: toAccountID,
displayName: participant?.displayName,
login: participant?.login,
},
[fromAccountID]: {
accountID: fromAccountID,
avatar: deprecatedCurrentUserPersonalDetails?.avatar,
displayName: deprecatedCurrentUserPersonalDetails?.displayName,
login: deprecatedCurrentUserPersonalDetails?.login,
},
};

optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = {
...(transactionThreadReportID && {transactionThreadReportID}),
...(isFromOneTransactionReport && {isFromOneTransactionReport}),
...transaction,
};

if (policy) {
optimisticSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy;
}

if (iouReport) {
optimisticSnapshotData[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`] = iouReport;
}

if (iouReport && iouAction) {
optimisticSnapshotData[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`] = {[iouAction.reportActionID]: iouAction};
}

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}` as const,
value: {
search: {
type: currentSearchQueryJSON.type,
status: currentSearchQueryJSON.status,
hasResults: true,
isLoading: false,
},
data: optimisticSnapshotData,
},
},
];

if (currentSearchQueryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) {
const newFlatFilters = currentSearchQueryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM);
// For a group-by:from view, also pre-populate the per-member transactions snapshot
// so that opening the group row immediately shows the new transaction.
if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) {
const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM);
newFlatFilters.push({
key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: fromAccountID}],
});

const groupTransactionsQueryJSON = buildSearchQueryJSON(
buildSearchQueryString({
...currentSearchQueryJSON,
...queryJSON,
groupBy: undefined,
flatFilters: newFlatFilters,
}),
);

if (groupTransactionsQueryJSON?.hash) {
if (groupTransactionsQueryJSON?.hash && !writtenHashes.has(groupTransactionsQueryJSON.hash)) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${groupTransactionsQueryJSON.hash}` as const,
Expand All @@ -229,17 +254,45 @@ function getSearchOnyxUpdate({
hasResults: true,
isLoading: false,
},
data: optimisticSnapshotData,
data: baseSnapshotData,
},
});
writtenHashes.add(groupTransactionsQueryJSON.hash);
}
}
};

// 1. Always cover the currently-active search query. Its snapshot may not exist yet
// (e.g. user opened the filter for the first time while offline), but Onyx MERGE will create it.
const currentSearchQueryJSON = getCurrentSearchQueryJSON();
if (currentSearchQueryJSON) {
writeForQuery(currentSearchQueryJSON, allSnapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}`]);
}

// 2. Fan out to every other loaded snapshot whose recorded query also matches this transaction.
// This catches cases like creating an expense from a chat while a `from:<me>` filter or
// `groupBy:from` view is loaded but not the currently active search. The hash→query map is
// stored in a dedicated Onyx key (not on the snapshot) so SEARCH API responses can't wipe it.
const queryByHash = getSearchQueryByHash();
for (const [hashString, queryString] of Object.entries(queryByHash)) {
if (!queryString) {
continue;
}
const queryJSON = buildSearchQueryJSON(queryString);
if (!queryJSON) {
continue;
}
writeForQuery(queryJSON, allSnapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hashString}`]);
}

return {
optimisticData,
successData,
};
if (optimisticData.length === 0) {
return;
}

return {
optimisticData,
successData,
};
}

export {getSearchOnyxUpdate, shouldOptimisticallyUpdateSearch};
44 changes: 44 additions & 0 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,40 @@ Onyx.connectWithoutView({
callback: (value) => (recentAttendees = value),
});

let searchQueryByHash: Record<string, string> = {};
Onyx.connect({
key: ONYXKEYS.SEARCH_QUERY_BY_HASH,
callback: (value) => {
searchQueryByHash = value ?? {};
},
});

let allSnapshots: OnyxCollection<OnyxTypes.SearchResults> = {};
let knownSnapshotHashes = new Set<string>();
Onyx.connect({
Comment thread
aswin-s marked this conversation as resolved.
key: ONYXKEYS.COLLECTION.SNAPSHOT,
waitForCollectionCallback: true,
callback: (value) => {
allSnapshots = value ?? {};
// Keep SEARCH_QUERY_BY_HASH bounded by mirroring the snapshot collection's lifecycle:
// when a snapshot disappears, drop its query entry so the map can never outgrow it.
const snapshotPrefixLength = ONYXKEYS.COLLECTION.SNAPSHOT.length;
const currentHashes = new Set(Object.keys(allSnapshots).map((k) => k.slice(snapshotPrefixLength)));
// Reconcile against persisted SEARCH_QUERY_BY_HASH too, so entries whose snapshots were evicted
// before this JS session get pruned on first sync (not just hashes seen since startup).
const candidates = new Set<string>([...knownSnapshotHashes, ...Object.keys(searchQueryByHash)]);
const removed = [...candidates].filter((h) => !currentHashes.has(h));
if (removed.length > 0) {
const evictions: Record<string, string | null> = {};
for (const h of removed) {
evictions[h] = null;
}
Onyx.merge(ONYXKEYS.SEARCH_QUERY_BY_HASH, evictions);
}
knownSnapshotHashes = currentHashes;
},
});

function getAllPersonalDetails(): OnyxTypes.PersonalDetailsList {
return allPersonalDetails;
}
Expand Down Expand Up @@ -157,6 +191,14 @@ function getRecentAttendees(): OnyxEntry<Attendee[]> {
return recentAttendees;
}

function getAllSnapshots(): OnyxCollection<OnyxTypes.SearchResults> {
return allSnapshots;
}

function getSearchQueryByHash(): Record<string, string> {
return searchQueryByHash;
}

/**
* This function uses Onyx.connect and should be replaced with useOnyx for reactive data access.
* TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721)
Expand Down Expand Up @@ -227,6 +269,8 @@ export {
getAllTransactionDrafts,
getCurrentUserPersonalDetails,
getRecentAttendees,
getAllSnapshots,
getSearchQueryByHash,
// eslint-disable-next-line @typescript-eslint/no-deprecated
buildParticipantsPolicyTags,
// TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook
Expand Down
8 changes: 8 additions & 0 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,14 @@ function getOnyxLoadingData(
},
];

// Side effect: record this query string under SEARCH_QUERY_BY_HASH so IOU optimistic updates
// can later fan to every loaded snapshot whose query matches. Done here (not via optimisticData)
// because this function's return type only allows snapshot keys; the matching eviction lives
// in the SNAPSHOT subscription in IOU/index.ts.
if (queryJSON?.inputQuery) {
Onyx.merge(ONYXKEYS.SEARCH_QUERY_BY_HASH, {[hash]: queryJSON.inputQuery});
}

const finallyData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
Expand Down
Loading
Loading