diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 5f38fdebce2a..743784a641fa 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -455,7 +455,20 @@ function getRawFilterListFromQuery(rawQuery: SearchQueryString) { return undefined; } +// Cache for buildSearchQueryJSON to avoid re-running the PEG parser for identical queries. +// This is a pure function called from 64+ sites — many fire during the same render cycle +// with identical query strings, each running the full parser from scratch. +const buildSearchQueryJSONCache = new Map(); +const BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE = 50; +const BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR = '\x00'; // Null byte prevents collisions if query/rawQuery contain arbitrary strings + function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString) { + const cacheKey = rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query; + if (buildSearchQueryJSONCache.has(cacheKey)) { + const cached = buildSearchQueryJSONCache.get(cacheKey); + return cached ? {...cached} : cached; + } + try { const result = parseSearchQuery(query) as SearchQueryJSON; const flatFilters = getFilters(result); @@ -487,7 +500,15 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt result.rawFilterList = getRawFilterListFromQuery(rawQuery); } - return result; + if (buildSearchQueryJSONCache.size >= BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE) { + const firstKey = buildSearchQueryJSONCache.keys().next().value; + if (firstKey !== undefined) { + buildSearchQueryJSONCache.delete(firstKey); + } + } + buildSearchQueryJSONCache.set(cacheKey, result); + + return {...result}; } catch (e) { console.error(`Error when parsing SearchQuery: "${query}"`, e); } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 21769cab2bcf..7aac5ba59619 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -30,6 +30,14 @@ const personalDetailsFakeData = { }, } as Record; +jest.mock('@libs/SearchParser/searchParser', () => { + const actual = jest.requireActual<{parse: (...args: unknown[]) => unknown}>('@libs/SearchParser/searchParser'); + return { + ...actual, + parse: jest.fn(actual.parse), + }; +}); + jest.mock('@libs/PersonalDetailsUtils', () => { const actual = jest.requireActual('@libs/PersonalDetailsUtils'); return { @@ -1287,4 +1295,80 @@ describe('SearchQueryUtils', () => { expect(result).toContain('merchant:Amazon'); }); }); + + describe('buildSearchQueryJSON cache', () => { + test('mutating the returned object does not affect subsequent calls for the same query', () => { + const query = `type:expense groupBy:category view:bar date:last-month merchant:test${Date.now()}`; + + const first = buildSearchQueryJSON(query); + if (first) { + first.groupBy = undefined; + first.view = CONST.SEARCH.VIEW.TABLE; + } + + const second = buildSearchQueryJSON(query); + + expect(second?.groupBy).toBe('category'); + expect(second?.view).toBe('bar'); + }); + + test('returns equal result on repeated calls with the same query', () => { + const query = 'type:expense status:outstanding'; + + const first = buildSearchQueryJSON(query); + const second = buildSearchQueryJSON(query); + + expect(first).toEqual(second); + }); + + test('returns independent results for the same query with different rawQuery values', () => { + const query = 'type:expense'; + const rawQueryA = 'type:expense status:drafts'; + const rawQueryB = 'type:expense status:paid'; + + const resultA = buildSearchQueryJSON(query, rawQueryA); + const resultB = buildSearchQueryJSON(query, rawQueryB); + + expect(resultA?.rawFilterList).not.toEqual(resultB?.rawFilterList); + }); + + test('does not cache a failed parse result so subsequent calls retry the parser', () => { + // Force the parser to throw on the first call only, then succeed on the second. + // Verifies that a failed parse is not stored in the cache. + const searchParserModule: {parse: jest.Mock} = jest.requireMock('@libs/SearchParser/searchParser'); + const originalImpl = jest.requireActual<{parse: (...args: unknown[]) => unknown}>('@libs/SearchParser/searchParser').parse; + + const query = `type:expense merchant:cache-err-test${Date.now()}`; + let callCount = 0; + searchParserModule.parse.mockImplementation((...args: unknown[]) => { + callCount++; + if (callCount === 1) { + throw new Error('Simulated parser failure'); + } + return originalImpl(...args); + }); + + const firstResult = buildSearchQueryJSON(query); + const secondResult = buildSearchQueryJSON(query); + + expect(firstResult).toBeUndefined(); + expect(secondResult?.type).toBe('expense'); + + searchParserModule.parse.mockImplementation(originalImpl); + }); + + test('evicts the oldest entry when the cache exceeds max size', () => { + // Insert 51 entries (max is 50) to trigger eviction, then verify + // the evicted entry can still be re-parsed correctly. + const uniquePrefix = `type:expense merchant:evict${Date.now()}x`; + for (let i = 0; i < 51; i++) { + buildSearchQueryJSON(`${uniquePrefix}${i}`); + } + + const afterEviction = buildSearchQueryJSON(`${uniquePrefix}0`); + + expect(afterEviction).toBeDefined(); + expect(afterEviction?.type).toBe('expense'); + }); + }); });