From 94fabff6ea001f733f5e441641dd6ccca1f778e6 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 10:45:40 +0200 Subject: [PATCH 1/5] Distinguish stale search results from a real request failure The error view treated any non-invalid-query code as stale, so a server rejection (e.g. EXP_ERROR) offered refresh copy instead of a retry. The no-response case is now an explicit NO_RESPONSE sentinel in JSON_CODE, so only a request that never reached the server shows the refresh copy. Any real code keeps the error copy and gets a Try again button. Co-authored-by: Apex --- src/CONST/index.ts | 2 ++ src/components/Search/index.tsx | 28 ++++++++++++++++++++-------- src/libs/actions/Search.ts | 6 +++--- tests/ui/SearchPageTest.tsx | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67f6ad2c0904..18ab07c17143 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2677,6 +2677,8 @@ const CONST = { UTILITIES: 'Utilities', }, JSON_CODE: { + // Client-side sentinel, never sent by the server: the request failed without a usable response code + NO_RESPONSE: 0, SUCCESS: 200, BAD_REQUEST: 400, INVALID_SEARCH_QUERY: 401, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6fb1f7fae215..3b6d053fbf03 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -74,6 +74,7 @@ import type {GetReportTableColumnStylesParams} from '@styles/utils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -1109,6 +1110,16 @@ function Search({ if (hasErrors) { const isInvalidQuery = responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY; + // failureData stores NO_RESPONSE when the request never got a server answer, so only the results' freshness is in + // doubt and the refresh copy fits. Any code the server did return marks a real failure and keeps the error copy. + const isStale = responseStatusCode === CONST.JSON_CODE.NO_RESPONSE; + let subtitleKey: TranslationPaths = 'errorPage.subtitle'; + if (isStale) { + subtitleKey = 'search.searchResults.staleResults.subtitle'; + } else if (isInvalidQuery) { + subtitleKey = 'errorPage.wrongTypeSubtitle'; + } + cancelNavigationSpans(); return ( @@ -1117,20 +1128,21 @@ function Search({ containerStyle={styles.searchBlockingErrorViewContainer} subtitleStyle={styles.textSupporting} title={ - isInvalidQuery - ? translate('errorPage.title', { + isStale + ? translate('search.searchResults.staleResults.title') + : translate('errorPage.title', { isBreakLine: shouldUseNarrowLayout, }) - : translate('search.searchResults.staleResults.title') } - subtitle={translate(isInvalidQuery ? 'errorPage.wrongTypeSubtitle' : 'search.searchResults.staleResults.subtitle')} - // A failed request leaves results that are out of date rather than broken, so that case gets the - // refresh copy and illustration. An invalid query keeps the error copy, since it really did fail. - {...(!isInvalidQuery && { + subtitle={translate(subtitleKey)} + {...(isStale && { illustration: 'FolderSync', illustrationWidth: variables.iconSizeUltraLarge, illustrationHeight: variables.iconSizeUltraLarge, - buttonTranslationKey: 'search.searchResults.staleResults.buttonText', + })} + // Retrying an invalid query won't help, so the retry button is only offered for other failures. + {...(!isInvalidQuery && { + buttonTranslationKey: isStale ? 'search.searchResults.staleResults.buttonText' : 'common.tryAgain', onButtonPress: () => { // A response replaces the snapshot's results rather than appending to them, so retrying at // the paginated offset would leave only that later page behind. Retry from the first page. diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1a449bb597cd..d48d5a9e9196 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -852,10 +852,10 @@ function getOnyxLoadingData( search: { type, ...(isSearchAPI && {isLoading: false}), - // 0 stands for "failed with no usable response code", which covers a network-level rejection that - // never reaches the server. A real HTTP failure overwrites it below once the response lands. Every + // NO_RESPONSE stands for "failed with no usable response code", which covers a network-level rejection + // that never reaches the server. A real HTTP failure overwrites it below once the response lands. Every // write of `errors` carries a code this way, so the error view never has to guess. - ...(isSearchRequest && {hash, responseJsonCode: 0}), + ...(isSearchRequest && {hash, responseJsonCode: CONST.JSON_CODE.NO_RESPONSE}), }, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, diff --git a/tests/ui/SearchPageTest.tsx b/tests/ui/SearchPageTest.tsx index caa0a79917b7..e9aa509fc00b 100644 --- a/tests/ui/SearchPageTest.tsx +++ b/tests/ui/SearchPageTest.tsx @@ -352,6 +352,39 @@ describe('SearchPageNarrow', () => { expect(screen.queryByText('Try again')).toBeNull(); }); + it('shows the error page with a retry button when the server rejected the query with a code other than invalid query', async () => { + // Given the page already requested the query, so an error that lands afterwards is its own and is kept + renderPage(); + + await act(async () => { + jest.runAllTimers(); + }); + + // When the server answers with a failure code that is not INVALID_SEARCH_QUERY + await setFailedSnapshot(CONST.JSON_CODE.EXP_ERROR); + + // Then the request really failed, so the error copy shows rather than the stale-results copy + expect(screen.getByText('Oops... Something went wrong')).toBeTruthy(); + expect(screen.getByText('Try again')).toBeTruthy(); + expect(screen.queryByText('Refresh needed')).toBeNull(); + }); + + it('shows the refresh copy when the request failed without a server response code', async () => { + renderPage(); + + await act(async () => { + jest.runAllTimers(); + }); + + // When the request failed before the server could answer, which failureData records as NO_RESPONSE + await setFailedSnapshot(CONST.JSON_CODE.NO_RESPONSE); + + // Then the results are only out of date, so the refresh copy shows + expect(screen.getByText('Refresh needed')).toBeTruthy(); + expect(screen.getByText('Refresh')).toBeTruthy(); + expect(screen.queryByText('Oops... Something went wrong')).toBeNull(); + }); + it('renders the empty state when a response without data reached the terminal loaded state', async () => { await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${failedQueryJSON?.hash}`, { From 8c077d86da8ba5d9bc56ee5d7c02dea1bb2edb0f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 12:11:23 +0200 Subject: [PATCH 2/5] Pick the Search error view from one lookup and test the persisted failure code --- src/components/Search/index.tsx | 85 ++++++++++---------- tests/unit/Search/searchSnapshotStateTest.ts | 24 +++++- 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3b6d053fbf03..96553f5c1e21 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -74,7 +74,6 @@ import type {GetReportTableColumnStylesParams} from '@styles/utils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -1109,54 +1108,58 @@ function Search({ } if (hasErrors) { - const isInvalidQuery = responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY; + cancelNavigationSpans(); + const retrySearch = () => { + // A response replaces the snapshot's results rather than appending to them, so retrying at + // the paginated offset would leave only that later page behind. Retry from the first page. + setOffset(0); + handleSearch({ + queryJSON, + searchKey: currentSearchKey, + offset: 0, + shouldCalculateTotals: shouldCalculateTotalsOnRetry, + prevReportsLength: filteredDataLength, + isLoading: !!searchResults?.search?.isLoading, + }); + }; // failureData stores NO_RESPONSE when the request never got a server answer, so only the results' freshness is in - // doubt and the refresh copy fits. Any code the server did return marks a real failure and keeps the error copy. - const isStale = responseStatusCode === CONST.JSON_CODE.NO_RESPONSE; - let subtitleKey: TranslationPaths = 'errorPage.subtitle'; - if (isStale) { - subtitleKey = 'search.searchResults.staleResults.subtitle'; - } else if (isInvalidQuery) { - subtitleKey = 'errorPage.wrongTypeSubtitle'; + // doubt and the refresh copy fits. Any code the server did return marks a real failure and keeps the error copy, + // and an invalid query gets no button because re-sending it cannot succeed. + let failureKind: 'stale' | 'invalidQuery' | 'failed' = 'failed'; + if (responseStatusCode === CONST.JSON_CODE.NO_RESPONSE) { + failureKind = 'stale'; + } else if (responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY) { + failureKind = 'invalidQuery'; } - - cancelNavigationSpans(); + const errorTitle = translate('errorPage.title', {isBreakLine: shouldUseNarrowLayout}); + const errorViewByKind = { + stale: { + title: translate('search.searchResults.staleResults.title'), + subtitle: translate('search.searchResults.staleResults.subtitle'), + illustration: 'FolderSync', + illustrationWidth: variables.iconSizeUltraLarge, + illustrationHeight: variables.iconSizeUltraLarge, + buttonTranslationKey: 'search.searchResults.staleResults.buttonText', + onButtonPress: retrySearch, + }, + invalidQuery: { + title: errorTitle, + subtitle: translate('errorPage.wrongTypeSubtitle'), + }, + failed: { + title: errorTitle, + subtitle: translate('errorPage.subtitle'), + buttonTranslationKey: 'common.tryAgain', + onButtonPress: retrySearch, + }, + } as const; return ( { - // A response replaces the snapshot's results rather than appending to them, so retrying at - // the paginated offset would leave only that later page behind. Retry from the first page. - setOffset(0); - handleSearch({ - queryJSON, - searchKey: currentSearchKey, - offset: 0, - shouldCalculateTotals: shouldCalculateTotalsOnRetry, - prevReportsLength: filteredDataLength, - isLoading: !!searchResults?.search?.isLoading, - }); - }, - })} + {...errorViewByKind[failureKind]} /> ); diff --git a/tests/unit/Search/searchSnapshotStateTest.ts b/tests/unit/Search/searchSnapshotStateTest.ts index fcd1f494274b..3047a215b2c3 100644 --- a/tests/unit/Search/searchSnapshotStateTest.ts +++ b/tests/unit/Search/searchSnapshotStateTest.ts @@ -197,6 +197,26 @@ describe('search snapshot terminal state', () => { expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.INVALID_SEARCH_QUERY); }); + it('persists a non-401 server failure code over the NO_RESPONSE placeholder written by failureData', async () => { + const queryJSON = getQueryJSON(); + // failureData lands first and writes NO_RESPONSE; the real code must overwrite it, otherwise the error view + // would show the "stale results" copy for a request the server actually rejected. + jest.mocked(makeRequestWithSideEffects).mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await Onyx.update(onyxData?.failureData ?? []); + await Onyx.update(onyxData?.finallyData ?? []); + return {jsonCode: CONST.JSON_CODE.EXP_ERROR}; + }); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + await waitForBatchedUpdates(); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.errors).toBeDefined(); + expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.EXP_ERROR); + expect(snapshot?.search?.responseJsonCode).not.toBe(CONST.JSON_CODE.NO_RESPONSE); + }); + it('does not persist a jsonCode for a successful response', async () => { const queryJSON = getQueryJSON(); jest.mocked(makeRequestWithSideEffects).mockResolvedValueOnce({jsonCode: CONST.JSON_CODE.SUCCESS}); @@ -257,7 +277,7 @@ describe('search snapshot terminal state', () => { expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED); expect(snapshot?.errors).toBeDefined(); // There is no response to read a code from, but the errors still need one so the error view can - // classify them after a reload. 0 records "failed without a usable code" rather than leaving a gap. - expect(snapshot?.search?.responseJsonCode).toBe(0); + // classify them after a reload. NO_RESPONSE records "failed without a usable code" rather than leaving a gap. + expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.NO_RESPONSE); }); }); From e68ba70e0c794deb3ae66e0f8e2fb152ad807422 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 12:38:16 +0200 Subject: [PATCH 3/5] Reword search failure code comments per review feedback --- src/CONST/index.ts | 2 +- tests/unit/Search/searchSnapshotStateTest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 18ab07c17143..3055ab43598e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2677,7 +2677,7 @@ const CONST = { UTILITIES: 'Utilities', }, JSON_CODE: { - // Client-side sentinel, never sent by the server: the request failed without a usable response code + // Client-side placeholder, never sent by the server: the request failed without a usable response code NO_RESPONSE: 0, SUCCESS: 200, BAD_REQUEST: 400, diff --git a/tests/unit/Search/searchSnapshotStateTest.ts b/tests/unit/Search/searchSnapshotStateTest.ts index 3047a215b2c3..405c4ffcb9ef 100644 --- a/tests/unit/Search/searchSnapshotStateTest.ts +++ b/tests/unit/Search/searchSnapshotStateTest.ts @@ -199,7 +199,7 @@ describe('search snapshot terminal state', () => { it('persists a non-401 server failure code over the NO_RESPONSE placeholder written by failureData', async () => { const queryJSON = getQueryJSON(); - // failureData lands first and writes NO_RESPONSE; the real code must overwrite it, otherwise the error view + // failureData lands first and writes NO_RESPONSE. The real code must overwrite it, otherwise the error view // would show the "stale results" copy for a request the server actually rejected. jest.mocked(makeRequestWithSideEffects).mockImplementationOnce(async (_command, _parameters, onyxData) => { await Onyx.update(onyxData?.optimisticData ?? []); From fe8a46df4570e4d64a5c847bfda956e2d086f65b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 14:29:34 +0200 Subject: [PATCH 4/5] Move Search failure kinds into CONST --- src/CONST/index.ts | 6 ++++++ src/components/Search/index.tsx | 13 +++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3055ab43598e..469b86b72fb9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7262,6 +7262,12 @@ const CONST = { LOADING: 'loading', LOADED: 'loaded', }, + // How the Search error view classifies a failed request from the snapshot's responseJsonCode. + FAILURE_KIND: { + STALE: 'stale', + INVALID_QUERY: 'invalidQuery', + FAILED: 'failed', + }, ACTION_FILTERS: { SUBMIT: 'submit', APPROVE: 'approve', diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 96553f5c1e21..aea1479aff19 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -87,6 +87,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; @@ -1125,15 +1126,15 @@ function Search({ // failureData stores NO_RESPONSE when the request never got a server answer, so only the results' freshness is in // doubt and the refresh copy fits. Any code the server did return marks a real failure and keeps the error copy, // and an invalid query gets no button because re-sending it cannot succeed. - let failureKind: 'stale' | 'invalidQuery' | 'failed' = 'failed'; + let failureKind: ValueOf = CONST.SEARCH.FAILURE_KIND.FAILED; if (responseStatusCode === CONST.JSON_CODE.NO_RESPONSE) { - failureKind = 'stale'; + failureKind = CONST.SEARCH.FAILURE_KIND.STALE; } else if (responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY) { - failureKind = 'invalidQuery'; + failureKind = CONST.SEARCH.FAILURE_KIND.INVALID_QUERY; } const errorTitle = translate('errorPage.title', {isBreakLine: shouldUseNarrowLayout}); const errorViewByKind = { - stale: { + [CONST.SEARCH.FAILURE_KIND.STALE]: { title: translate('search.searchResults.staleResults.title'), subtitle: translate('search.searchResults.staleResults.subtitle'), illustration: 'FolderSync', @@ -1142,11 +1143,11 @@ function Search({ buttonTranslationKey: 'search.searchResults.staleResults.buttonText', onButtonPress: retrySearch, }, - invalidQuery: { + [CONST.SEARCH.FAILURE_KIND.INVALID_QUERY]: { title: errorTitle, subtitle: translate('errorPage.wrongTypeSubtitle'), }, - failed: { + [CONST.SEARCH.FAILURE_KIND.FAILED]: { title: errorTitle, subtitle: translate('errorPage.subtitle'), buttonTranslationKey: 'common.tryAgain', From 19debe99fe8cbd87e4823e8ad98affff2ebcba80 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 18 Sep 2026 14:29:51 +0200 Subject: [PATCH 5/5] Drop redundant FAILURE_KIND comment --- src/CONST/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 469b86b72fb9..d4bcb7a64048 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7262,7 +7262,6 @@ const CONST = { LOADING: 'loading', LOADED: 'loaded', }, - // How the Search error view classifies a failed request from the snapshot's responseJsonCode. FAILURE_KIND: { STALE: 'stale', INVALID_QUERY: 'invalidQuery',