Skip to content
Merged
8 changes: 3 additions & 5 deletions src/hooks/useSearchLoadingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined, searchRes

const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0;

// Show page-level skeleton when no data has ever arrived for this query,
// or when card feeds are still loading for card-grouped searches.
// Once data arrives (even empty []), Search mounts and handles its own
// loading/empty states internally via shouldShowLoadingState.
// When errors are present, let Search mount so it can render FullPageErrorView.
// Keep the page skeleton visible until the first response arrives and while card feeds load.
// SearchPage turns a completed response with no data into an empty result, so Search can render its empty state.
// Errors bypass the missing-data skeleton so Search can render FullPageErrorView.
return (hasNoData && !hasErrors) || isCardFeedsLoading;
}

Expand Down
7 changes: 4 additions & 3 deletions src/hooks/useSearchPageSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | undefined) {
const hash = queryJSON?.hash;
const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true);

// Derived primitives so effects do not depend on the whole snapshot object (new reference every
// Onyx merge) while exhaustive-deps still sees every transition that matters for firing search().
// Depend on the values that can trigger a search instead of the whole snapshot, which gets a new reference after every Onyx merge.
const isSnapshotDataLoaded = queryJSON ? isSearchDataLoaded(currentSearchResults, queryJSON) : false;
// Keep `isLoading` as a dependency so an unresolved search retries when temporary search prevention changes it to false.
const isSnapshotSearchLoading = !!currentSearchResults?.search?.isLoading;

// Clear selected transactions when navigating to a different search query
Expand Down Expand Up @@ -69,7 +69,8 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | undefined) {
lastSavedSearchHash = hash;
}

if (isSnapshotDataLoaded || isSnapshotSearchLoading) {
// A persisted `isLoading` value may be stale after a reload. Only skip resolved snapshots and let `search()` ignore requests that are already running.
if (isSnapshotDataLoaded) {
return;
}
const shouldSkipWaitForWrites = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
Expand Down
15 changes: 14 additions & 1 deletion src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4824,6 +4824,13 @@ function shouldShowEmptyState(isDataLoaded: boolean, dataLength: number, type: S
return !isDataLoaded || dataLength === 0 || !type || !Object.values(CONST.SEARCH.DATA_TYPES).includes(type);
}

/**
* Uses `search.state`, which tracks API requests, instead of `search.isLoading`, which also tracks temporary UI loading.
*/
function isSearchPending(searchResults: SearchResults | undefined) {
return searchResults?.search?.state === CONST.SEARCH.SNAPSHOT_STATE.LOADING;
}

function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: Readonly<SearchQueryJSON> | undefined) {
const queryJSONHash =
queryJSON && searchResults?.search
Expand All @@ -4833,8 +4840,13 @@ function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON:
sortOrder: searchResults.search.sortOrder,
}).primaryHash
: queryJSON?.hash;
const state = searchResults?.search?.state;
// A response can finish without writing data or errors, so `loaded` and `error` also count as resolved.
// The type and hash checks below keep results from an earlier query out.
const isTerminal = state === CONST.SEARCH.SNAPSHOT_STATE.LOADED || state === CONST.SEARCH.SNAPSHOT_STATE.ERROR;
const hasResolved = searchResults?.data != null || searchResults?.errors != null || isTerminal;

return (searchResults?.data != null || searchResults?.errors != null) && searchResults.search?.type === queryJSON?.type && searchResults.search?.hash === queryJSONHash;
return hasResolved && searchResults?.search?.type === queryJSON?.type && searchResults?.search?.hash === queryJSONHash;
}

function getValidGroupBy(groupBy: string | undefined): ValueOf<typeof CONST.SEARCH.GROUP_BY> | undefined {
Expand Down Expand Up @@ -6558,6 +6570,7 @@ export {
shouldShowEmptyState,
compareValues,
isSearchDataLoaded,
isSearchPending,
getValidGroupBy,
getTypeOptions,
getSortByOptions,
Expand Down
20 changes: 11 additions & 9 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,19 +719,15 @@ function getOnyxLoadingData(
Onyx.merge(ONYXKEYS.SEARCH_QUERY_BY_HASH, {[hash]: queryJSON.inputQuery});
}

// successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so
// responses that do carry data stay consistent with the anti-stale isSearchDataLoaded check (which compares
// type/hash). On a success response without data, isSearchDataLoaded still resolves to false via its own data/errors gate;
// `state` is what marks that case as done once a future PR wires the read side to it. `isLoading` isn't set here
// because finallyData always runs right after and already clears it for isSearchAPI. Empty for the non-search
// callers of this helper so they don't pay for a meaningless `{search: {}}` merge on the SNAPSHOT key.
// A successful response may contain no snapshot data. Store `type` and `hash` with `loaded` so the UI can match it to the current query and show the empty state.
// Do not clear `isLoading` here because `finallyData` always does.
const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = isSearchRequest
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
value: {
search: {state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type},
search: {state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type, hash},
},
},
]
Expand Down Expand Up @@ -760,7 +756,7 @@ function getOnyxLoadingData(
search: {
type,
...(isSearchAPI && {isLoading: false}),
...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.ERROR}),
...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.ERROR, hash}),
},
errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
},
Expand Down Expand Up @@ -1063,8 +1059,14 @@ function search({
const startRequest = () =>
makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, successData, finallyData, failureData})
.then((result) => {
const response = result?.onyxData?.[0]?.value as OnyxSearchResponse;

// The UI treats a successful response with no snapshot data as an empty result, so record it for diagnosis.
if (result?.jsonCode === CONST.JSON_CODE.SUCCESS && response?.data === undefined) {
Log.info('[Search] loading_terminal_empty', false, {hash: queryJSON.hash, type: queryJSON.type});
}

if (shouldUpdateLastSearchParams) {
const response = result?.onyxData?.[0]?.value as OnyxSearchResponse;
const reports = Object.keys(response?.data ?? {})
.filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT))
.map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, ''));
Expand Down
9 changes: 5 additions & 4 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {searchInServer} from '@libs/actions/Report';
import {search} from '@libs/actions/Search';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
import {isSearchDataLoaded} from '@libs/SearchUIUtils';

import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
Expand Down Expand Up @@ -73,15 +74,16 @@ function SearchPage({route}: SearchPageProps) {

const [isSorting, setIsSorting] = useState(false);

const isCurrentSearchResolved = isSearchDataLoaded(currentSearchResults, currentSearchQueryJSON);
let searchResults: SearchResults | undefined;
if (currentSearchResults?.data != null || currentSearchResults?.errors) {
if (isCurrentSearchResolved && currentSearchResults?.search && currentSearchResults.data === undefined) {
searchResults = {...currentSearchResults, data: {}};
} else if (currentSearchResults?.data != null || currentSearchResults?.errors) {
searchResults = currentSearchResults;
} else if (isSorting) {
searchResults = lastNonEmptySearchResults;
}

const metadata = searchResults?.search;

useEffect(() => {
if (shouldUseNarrowLayout) {
return;
Expand Down Expand Up @@ -142,7 +144,6 @@ function SearchPage({route}: SearchPageProps) {
{shouldUseNarrowLayout ? (
<SearchPageNarrow
queryJSON={currentSearchQueryJSON}
metadata={metadata}
searchResults={searchResults}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
onSortPressedCallback={onSortPressedCallback}
Expand Down
8 changes: 3 additions & 5 deletions src/pages/Search/SearchPageNarrow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import Navigation from '@libs/Navigation/Navigation';
import {buildCannedSearchQuery} from '@libs/SearchQueryUtils';
import {isSearchDataLoaded} from '@libs/SearchUIUtils';
import {isSearchDataLoaded, isSearchPending} from '@libs/SearchUIUtils';
import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';

import variables from '@styles/variables';
Expand All @@ -39,7 +39,6 @@ import {search} from '@userActions/Search';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {SearchResults} from '@src/types/onyx';
import type {SearchResultsInfo} from '@src/types/onyx/SearchResults';

import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native';
import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react';
Expand All @@ -55,7 +54,6 @@ const ANIMATION_DURATION_IN_MS = 300;

type SearchPageNarrowProps = {
queryJSON?: SearchQueryJSON;
metadata?: SearchResultsInfo;
searchResults?: SearchResults;
isMobileSelectionModeEnabled: boolean;
onSortPressedCallback: () => void;
Expand All @@ -75,7 +73,6 @@ function SearchPageNarrow({
queryJSON,
searchResults,
isMobileSelectionModeEnabled,
metadata,
onSortPressedCallback,
searchOverlayContent,
onSearchContentReady,
Expand Down Expand Up @@ -231,7 +228,8 @@ function SearchPageNarrow({
}

const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON);
const shouldShowLoadingState = !isOffline && (!isDataLoaded || !!metadata?.isLoading);
// Use the request state because `isLoading` also covers temporary UI loading that should not keep this bar visible.
const shouldShowLoadingState = !isOffline && (!isDataLoaded || isSearchPending(searchResults));
const contentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(hasFilterBars) : undefined;

return (
Expand Down
4 changes: 2 additions & 2 deletions src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ function useSpendOverTimeData() {
const {convertToDisplayString} = useCurrencyListActions();
const {accountID, login} = useCurrentUserPersonalDetails();
const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`);
const isSearchLoading = !!searchResults?.search?.isLoading;

const {isOffline} = useNetwork();
const isFocused = useIsFocused();

const onConfigChanged = useEffectEvent(() => {
if (!queryJSON || isSearchLoading || isOffline) {
// `search.isLoading` is persisted and may be stale after a reload. Call `search()` again and let it ignore a request that is still running.
if (!queryJSON || isOffline) {
return;
}

Expand Down
111 changes: 111 additions & 0 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8433,12 +8433,123 @@ describe('SearchUIUtils', () => {
expect(SearchUIUtils.isSearchDataLoaded(makeSearchResults(), undefined)).toBe(false);
});

it('should return true on a response with no data that reached a terminal loaded state (type and hash match)', () => {
const results = makeSearchResults({
data: undefined,
errors: undefined,
search: {
hasMoreResults: false,
hasResults: false,
offset: 0,
hash: queryJSON?.hash ?? 0,
isLoading: false,
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
sortBy: queryJSON?.sortBy ?? 'date',
sortOrder: queryJSON?.sortOrder ?? 'desc',
state: CONST.SEARCH.SNAPSHOT_STATE.LOADED,
},
});
expect(SearchUIUtils.isSearchDataLoaded(results, queryJSON)).toBe(true);
});

it('should return true when the request reached a terminal error state', () => {
const results = makeSearchResults({
data: undefined,
errors: undefined,
search: {
hasMoreResults: false,
hasResults: false,
offset: 0,
hash: queryJSON?.hash ?? 0,
isLoading: false,
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
sortBy: queryJSON?.sortBy ?? 'date',
sortOrder: queryJSON?.sortOrder ?? 'desc',
state: CONST.SEARCH.SNAPSHOT_STATE.ERROR,
},
});
expect(SearchUIUtils.isSearchDataLoaded(results, queryJSON)).toBe(true);
});

it('should return false while a request is still loading with no data yet', () => {
const results = makeSearchResults({
data: undefined,
errors: undefined,
search: {
hasMoreResults: false,
hasResults: false,
offset: 0,
hash: queryJSON?.hash ?? 0,
isLoading: true,
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
sortBy: queryJSON?.sortBy ?? 'date',
sortOrder: queryJSON?.sortOrder ?? 'desc',
state: CONST.SEARCH.SNAPSHOT_STATE.LOADING,
},
});
expect(SearchUIUtils.isSearchDataLoaded(results, queryJSON)).toBe(false);
});

it('should return false when a terminal state belongs to a stale snapshot (hash mismatch)', () => {
const results = makeSearchResults({
data: undefined,
errors: undefined,
search: {
hasMoreResults: false,
hasResults: false,
offset: 0,
hash: (queryJSON?.hash ?? 0) + 1,
isLoading: false,
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
sortBy: queryJSON?.sortBy ?? 'date',
sortOrder: queryJSON?.sortOrder ?? 'desc',
state: CONST.SEARCH.SNAPSHOT_STATE.LOADED,
},
});
expect(SearchUIUtils.isSearchDataLoaded(results, queryJSON)).toBe(false);
});

it('should return false when searchResults.search is undefined', () => {
const results = makeSearchResults({search: undefined});
expect(SearchUIUtils.isSearchDataLoaded(results, queryJSON)).toBe(false);
});
});

describe('Test isSearchPending', () => {
const queryJSON = buildSearchQueryJSON('type:expense');

function makeSearch(state: OnyxTypes.SearchResults['search']['state']): OnyxTypes.SearchResults {
return {
data: {personalDetailsList: {}},
search: {
hasMoreResults: false,
hasResults: true,
offset: 0,
hash: queryJSON?.hash ?? 0,
isLoading: false,
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
sortBy: queryJSON?.sortBy ?? 'date',
sortOrder: queryJSON?.sortOrder ?? 'desc',
state,
},
};
}

it('should return true only while the snapshot state is loading', () => {
expect(SearchUIUtils.isSearchPending(makeSearch(CONST.SEARCH.SNAPSHOT_STATE.LOADING))).toBe(true);
});

it('should return false for the terminal loaded and error states', () => {
expect(SearchUIUtils.isSearchPending(makeSearch(CONST.SEARCH.SNAPSHOT_STATE.LOADED))).toBe(false);
expect(SearchUIUtils.isSearchPending(makeSearch(CONST.SEARCH.SNAPSHOT_STATE.ERROR))).toBe(false);
});

it('should return false when the state is absent or searchResults is undefined', () => {
expect(SearchUIUtils.isSearchPending(makeSearch(undefined))).toBe(false);
expect(SearchUIUtils.isSearchPending(undefined)).toBe(false);
});
});

describe('Test isSearchResultsEmpty', () => {
it('should return true when all transactions have delete pending action', () => {
const results: OnyxTypes.SearchResults = {
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/Search/searchSnapshotStateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('search snapshot terminal state', () => {

const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const);
expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED);
expect(snapshot?.search?.hash).toBe(queryJSON.hash);
expect(snapshot?.search?.hasResults).toBe(true);
});

Expand All @@ -114,6 +115,7 @@ describe('search snapshot terminal state', () => {
const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const);
// finallyData runs after failureData; the error state must survive it.
expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.ERROR);
expect(snapshot?.search?.hash).toBe(queryJSON.hash);
});

it('reaches a terminal state when a successful response resolves without any snapshot data', async () => {
Expand All @@ -125,6 +127,8 @@ describe('search snapshot terminal state', () => {

const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const);
expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED);
// The hash lets the UI match this completed request to the current query.
expect(snapshot?.search?.hash).toBe(queryJSON.hash);
});

it('resolves the snapshot to error when the request promise rejects, instead of staying loading', async () => {
Expand Down
Loading
Loading