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
39 changes: 37 additions & 2 deletions src/libs/actions/IOU/SearchUpdate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {SearchQueryJSON} from '@components/Search/types';

import {isExpenseReport, isOptimisticPersonalDetail} from '@libs/ReportUtils';
import {buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils';
import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils';
import {getSuggestedSearches, isEligibleForStatus} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -113,6 +113,30 @@ function shouldOptimisticallyUpdateSearch(
return shouldOptimisticallyUpdateByStatus && validSearchTypes && matchesFilterQuery;
}

/**
* The default Spend > Expenses and Reports pages render from the canned suggested-search snapshots
* (`type:expense` / `type:expense_report`). Those hashes are normally added to SEARCH_QUERY_BY_HASH
* only as a side effect of the `search()` action when the page is actually opened (see Search.ts).
* If the user never opened the page before going offline, that hash is absent from the map, so the
* fan-out loop in `getSearchOnyxUpdate` never patches the snapshot the page reads and it stays empty.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-17 (docs)

The phrase "fan-out" is AI-generated jargon that rarely appears in engineer-written code. It should be replaced with plain, direct language.

Rewrite the comment to drop "fan-out", e.g.:

 * If the user never opened the page before going offline, that hash is absent from the map, so the
 * loop in `getSearchOnyxUpdate` never patches the snapshot the page reads and it stays empty.

Reviewed at: 6f11102 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

*
* These canned queries are deterministic and don't depend on a visit, so we register their hashes here
* and let the existing loop patch them exactly as it would after a visit. This is cheap: the query
* strings are trivial to build and `buildSearchQueryJSON` is internally cached.
*/
function getDefaultSearchQueriesByHash(): Record<string, string> {
const defaultQueryStrings = [buildCannedSearchQuery(), buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT})];
const queriesByHash: Record<string, string> = {};
for (const queryString of defaultQueryStrings) {
const queryJSON = buildSearchQueryJSON(queryString);
if (!queryJSON) {
continue;
}
queriesByHash[queryJSON.hash] = queryString;
}
return queriesByHash;
}

function getSearchOnyxUpdate({
participant,
transaction,
Expand Down Expand Up @@ -193,6 +217,12 @@ function getSearchOnyxUpdate({
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const,
value: {
search: {
// `hash` is what makes an optimistically-created snapshot renderable. The Search page gates
// rendering on `isSearchDataLoaded`, which requires `snapshot.search.hash === queryJSON.hash`.
// When the page was visited before, `search()` already stamped this hash on the snapshot, so a
// partial MERGE renders; on a never-visited page the snapshot is created by this MERGE, so it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-16 (docs)

This comment uses a semicolon to join two independent clauses. Comments should read as plain, natural sentences, so a semicolon that separates two complete thoughts should be split into two separate sentences.

Split the clause into two sentences, e.g.:

// When the page was visited before, `search()` already stamped this hash on the snapshot, so a
// partial MERGE renders. On a never-visited page the snapshot is created by this MERGE, so it
// must carry the hash itself or `isSearchDataLoaded` stays false and the page shows "Nothing to show".

Reviewed at: 6f11102 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

// must carry the hash itself or `isSearchDataLoaded` stays false and the page shows "Nothing to show".
hash: queryJSON.hash,
type: queryJSON.type,
hasResults: true,
isLoading: false,
Expand Down Expand Up @@ -252,6 +282,8 @@ function getSearchOnyxUpdate({
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${groupTransactionsQueryJSON.hash}` as const,
value: {
search: {
// See the note above: the per-member snapshot must carry its own hash to be renderable when created optimistically.
hash: groupTransactionsQueryJSON.hash,
type: groupTransactionsQueryJSON.type,
offset: 0,
hasMoreResults: false,
Expand All @@ -277,7 +309,10 @@ function getSearchOnyxUpdate({
// 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();
// The deterministic default canned hashes (see getDefaultSearchQueriesByHash) are merged in so the
// default Spend > Expenses / Reports pages are patched even when they were never visited. Onyx-recorded
// queries take precedence so a real visited entry is never shadowed by the canned default.
const queryByHash = {...getDefaultSearchQueriesByHash(), ...getSearchQueryByHash()};
for (const [hashString, queryString] of Object.entries(queryByHash)) {
if (!queryString) {
continue;
Expand Down
44 changes: 44 additions & 0 deletions tests/actions/IOU/SearchUpdateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {getSearchOnyxUpdate, shouldOptimisticallyUpdateSearch} from '@libs/actio
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
import '@libs/actions/IOU/MoneyRequest';
import type * as PolicyUtils from '@libs/PolicyUtils';
import type * as SearchQueryUtils from '@libs/SearchQueryUtils';

import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
import {buildCannedSearchQuery} from '@src/libs/SearchQueryUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';

Expand Down Expand Up @@ -546,5 +548,47 @@ describe('actions/IOU', () => {
});
expect(result).toBeUndefined();
});

it('patches the default Spend > Expenses snapshot even when the page was never visited', async () => {
// Compute the real canned Expenses hash from the unmocked helpers.
const actualSearchQueryUtils = jest.requireActual<typeof SearchQueryUtils>('@src/libs/SearchQueryUtils');
const cannedExpensesQuery = actualSearchQueryUtils.buildCannedSearchQuery();
const cannedExpensesHash = actualSearchQueryUtils.buildSearchQueryJSON(cannedExpensesQuery)?.hash;

// Feed the real canned query strings through the mocked builder so getSearchOnyxUpdate can
// register the canned hashes (the mock returns undefined by default).
jest.mocked(buildCannedSearchQuery).mockImplementation(actualSearchQueryUtils.buildCannedSearchQuery);

// Simulate a never-visited Spend > Expenses page: SEARCH_QUERY_BY_HASH holds no entry for the
// canned hash and the active search (mocked) is a different hash.
await Onyx.set(ONYXKEYS.SEARCH_QUERY_BY_HASH, {});
await waitForBatchedUpdates();

const iouReport: Report = {
...createRandomReport(2, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
};

const result = getSearchOnyxUpdate({
transaction: {...createRandomTransaction(1)},
participant: {accountID: 42, login: 'test@test.com'},
iouReport,
iouAction: undefined,
policy: undefined,
transactionThreadReportID: undefined,
isFromOneTransactionReport: false,
isInvoice: false,
});

const cannedSnapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${cannedExpensesHash}`;
const cannedUpdate = result?.optimisticData?.find((update) => update.key === cannedSnapshotKey);
expect(cannedUpdate).toBeDefined();

// The snapshot must carry its own `hash` or the never-visited page's `isSearchDataLoaded` gate stays
// false and the page renders "Nothing to show" even though the transaction data was merged in.
expect(cannedUpdate?.value).toHaveProperty('search.hash', cannedExpensesHash);
});
});
});
Loading