diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 44ec0632b64b..7193af2b66c7 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8391,6 +8391,9 @@ const CONST = {
CHRONOS_TIMER_BUTTON: 'HeaderView-ChronosTimerButton',
DETAILS_BUTTON: 'HeaderView-DetailsButton',
},
+ BLOCKING_VIEW: {
+ RETRY_BUTTON: 'BlockingView-RetryButton',
+ },
SEARCH: {
SEARCH_BUTTON: 'Search-SearchButton',
TRANSACTION_GROUP_LIST_ITEM: 'Search-TransactionGroupListItem',
diff --git a/src/components/BlockingViews/BlockingView.tsx b/src/components/BlockingViews/BlockingView.tsx
index 4d92fab92fc7..0e2cfacf6193 100644
--- a/src/components/BlockingViews/BlockingView.tsx
+++ b/src/components/BlockingViews/BlockingView.tsx
@@ -1,3 +1,4 @@
+import Button from '@components/ButtonComposed';
import Icon from '@components/Icon';
import Lottie from '@components/Lottie';
import type DotLottieAnimation from '@components/LottieAnimations/types';
@@ -5,6 +6,7 @@ import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
@@ -12,6 +14,7 @@ import useAbsentPageSpan from '@libs/telemetry/useAbsentPageSpan';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {ImageContentFit} from 'expo-image';
@@ -48,6 +51,12 @@ type BaseBlockingViewProps = {
/** Function to call when pressing the navigation link */
onLinkPress?: () => void;
+ /** Translation key for an optional CTA button rendered below the subtitle */
+ buttonTranslationKey?: TranslationPaths;
+
+ /** Function to call when pressing the CTA button. The button only renders when this and `buttonTranslationKey` are both provided */
+ onButtonPress?: () => void;
+
/** Whether we should embed the link with subtitle */
shouldEmbedLinkWithSubtitle?: boolean;
@@ -110,6 +119,8 @@ function BlockingView({
subtitleStyle,
linkTranslationKey,
subtitleKeyBelowLink,
+ buttonTranslationKey,
+ onButtonPress,
iconWidth = variables.iconSizeSuperLarge,
iconHeight = variables.iconSizeSuperLarge,
onLinkPress = () => Navigation.dismissModal(),
@@ -126,6 +137,7 @@ function BlockingView({
testID,
}: BlockingViewProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
const SubtitleWrapper = shouldEmbedLinkWithSubtitle ? Text : View;
const subtitleWrapperStyle = useMemo(
() => (shouldEmbedLinkWithSubtitle ? [styles.textAlignCenter] : [styles.alignItemsCenter, styles.justifyContentCenter]),
@@ -185,6 +197,14 @@ function BlockingView({
)}
+ {!!onButtonPress && !!buttonTranslationKey && (
+
+ )}
);
}
diff --git a/src/components/BlockingViews/FullPageErrorView.tsx b/src/components/BlockingViews/FullPageErrorView.tsx
index 4863b3bba29d..649a9e9444b7 100644
--- a/src/components/BlockingViews/FullPageErrorView.tsx
+++ b/src/components/BlockingViews/FullPageErrorView.tsx
@@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
+import type {TranslationPaths} from '@src/languages/types';
+
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import React from 'react';
@@ -34,9 +36,26 @@ type FullPageErrorViewProps = {
subtitleStyle?: StyleProp;
containerStyle?: StyleProp;
+
+ /** Translation key for an optional CTA button rendered below the subtitle */
+ buttonTranslationKey?: TranslationPaths;
+
+ /** Function to call when pressing the CTA button. The button only renders when this and `buttonTranslationKey` are both provided */
+ onButtonPress?: () => void;
};
-function FullPageErrorView({testID, children = null, shouldShow = false, title = '', subtitle = '', shouldForceFullScreen = false, subtitleStyle, containerStyle}: FullPageErrorViewProps) {
+function FullPageErrorView({
+ testID,
+ children = null,
+ shouldShow = false,
+ title = '',
+ subtitle = '',
+ shouldForceFullScreen = false,
+ subtitleStyle,
+ containerStyle,
+ buttonTranslationKey,
+ onButtonPress,
+}: FullPageErrorViewProps) {
const styles = useThemeStyles();
const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']);
@@ -52,9 +71,12 @@ function FullPageErrorView({testID, children = null, shouldShow = false, title =
iconWidth={variables.errorPageIconWidth}
iconHeight={variables.errorPageIconHeight}
title={title}
+ titleStyles={[styles.mt0, styles.mb2]}
subtitle={subtitle}
subtitleStyle={subtitleStyle}
- containerStyle={containerStyle}
+ containerStyle={[styles.gap5, containerStyle]}
+ buttonTranslationKey={buttonTranslationKey}
+ onButtonPress={onButtonPress}
/>
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 429c8b9434b6..ab37b2acd7b1 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -182,6 +182,10 @@ function Search({
const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]);
const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0, areAllMatchingItemsSelected);
+ // Retrying a failed page always resets pagination to the first page, so totals eligibility
+ // must be evaluated as if we're on the first page rather than the (possibly paginated) offset.
+ const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, hash, true, areAllMatchingItemsSelected);
+
const previousReportActions = usePrevious(reportActions);
const {translate} = useLocalize();
const searchListRef = useRef | null>(null);
@@ -942,6 +946,24 @@ function Search({
isBreakLine: shouldUseNarrowLayout,
})}
subtitle={translate(isInvalidQuery ? 'errorPage.wrongTypeSubtitle' : 'errorPage.subtitle')}
+ // Retrying an invalid query won't help, so the retry button is only offered for other errors.
+ {...(!isInvalidQuery && {
+ buttonTranslationKey: 'common.tryAgain',
+ onButtonPress: () => {
+ // A failed load-more clears the whole snapshot (data: null), so retrying with the
+ // paginated offset would refetch only the later page into an empty snapshot and drop
+ // the initial results. Reset pagination to the first page before retrying.
+ setOffset(0);
+ handleSearch({
+ queryJSON,
+ searchKey: currentSearchKey,
+ offset: 0,
+ shouldCalculateTotals: shouldCalculateTotalsOnRetry,
+ prevReportsLength: filteredDataLength,
+ isLoading: !!searchResults?.search?.isLoading,
+ });
+ },
+ })}
/>
);
diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts
index 8bc0ef93ad8e..201cae1c0d66 100644
--- a/src/hooks/useSearchPageSetup.ts
+++ b/src/hooks/useSearchPageSetup.ts
@@ -9,7 +9,7 @@ import {isSearchDataLoaded} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import {useFocusEffect} from '@react-navigation/native';
-import {useEffect} from 'react';
+import {useEffect, useState} from 'react';
import useNetwork from './useNetwork';
import usePrevious from './usePrevious';
@@ -36,7 +36,24 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) {
const hash = queryJSON?.hash;
const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true);
- // Depend on the values that can trigger a search instead of the whole snapshot, which gets a new reference after every Onyx merge.
+ // Tracks the jsonCode of the current query's most recent SEARCH response. It's the single source of
+ // truth for both fire paths (the page-level fire below and the user-driven re-search in
+ // SearchPage/SearchPageNarrow), so the error view can reliably tell an INVALID_SEARCH_QUERY apart from a
+ // retryable failure. `null` means no response has landed yet for the current query.
+ const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null);
+
+ // Reset on query change so a stale code from a previous query (e.g. INVALID_SEARCH_QUERY) can't
+ // misclassify the new query's failure and wrongly hide/show the Retry button. Adjusting state during
+ // render (rather than in an effect) is the React-recommended pattern for resetting state on a prop
+ // change and avoids the extra committed render an effect would cost.
+ const [prevHash, setPrevHash] = useState(hash);
+ if (hash !== prevHash) {
+ setPrevHash(hash);
+ setSearchRequestResponseStatusCode(null);
+ }
+
+ // 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().
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;
@@ -74,7 +91,11 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) {
return;
}
const shouldSkipWaitForWrites = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
- search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false, skipWaitForWrites: shouldSkipWaitForWrites});
+ // Capture the response code so an invalid query opened directly (deep link / initial mount) is
+ // classified correctly, instead of leaving the status at `null` and offering a pointless Retry.
+ search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false, skipWaitForWrites: shouldSkipWaitForWrites})?.then((jsonCode) =>
+ setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)),
+ );
}, [hash, isOffline, shouldUseLiveData, queryJSON, isSnapshotDataLoaded, isSnapshotSearchLoading, currentSearchKey, shouldCalculateTotals]);
useFocusEffect(() => {
@@ -87,6 +108,8 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) {
}
openSearch();
}, [isOffline, prevIsOffline]);
+
+ return {searchRequestResponseStatusCode, setSearchRequestResponseStatusCode};
}
export default useSearchPageSetup;
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index d4d78421d3c7..cd65dabf2294 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -50,7 +50,7 @@ function SearchPage({route}: SearchPageProps) {
const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState(undefined);
- useSearchPageSetup(currentSearchQueryJSON);
+ const {searchRequestResponseStatusCode, setSearchRequestResponseStatusCode} = useSearchPageSetup(currentSearchQueryJSON);
useSeedMyExpensesSearch();
// Adjust state during rendering rather than in a useEffect: the value is consumed in the same
@@ -108,18 +108,19 @@ function SearchPage({route}: SearchPageProps) {
setIsSorting(false);
}, [currentSearchResults?.isLoading, isSorting, prevIsLoading]);
- const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null);
-
- const handleSearchAction = useCallback((value: SearchParams | string) => {
- if (typeof value === 'string') {
- searchInServer(value);
- } else {
- setSearchRequestResponseStatusCode(null);
- search(value)?.then((jsonCode) => {
- setSearchRequestResponseStatusCode(Number(jsonCode ?? 0));
- });
- }
- }, []);
+ const handleSearchAction = useCallback(
+ (value: SearchParams | string) => {
+ if (typeof value === 'string') {
+ searchInServer(value);
+ } else {
+ setSearchRequestResponseStatusCode(null);
+ search(value)?.then((jsonCode) => {
+ setSearchRequestResponseStatusCode(Number(jsonCode ?? 0));
+ });
+ }
+ },
+ [setSearchRequestResponseStatusCode],
+ );
const onSortPressedCallback = useCallback(() => {
setIsSorting(true);
@@ -145,6 +146,8 @@ function SearchPage({route}: SearchPageProps) {
void;
onSortPressedCallback: () => void;
/** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */
searchOverlayContent: React.ReactNode;
@@ -73,6 +75,8 @@ function SearchPageNarrow({
queryJSON,
searchResults,
isMobileSelectionModeEnabled,
+ searchRequestResponseStatusCode,
+ setSearchRequestResponseStatusCode,
onSortPressedCallback,
searchOverlayContent,
onSearchContentReady,
@@ -96,8 +100,6 @@ function SearchPageNarrow({
const {saveScrollOffset} = useContext(ScrollOffsetContext);
const receiptDropTargetRef = useRef(null);
- const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null);
-
const scrollOffset = useSharedValue(0);
const topBarOffset = useSharedValue(StyleUtils.searchHeaderDefaultOffset);
@@ -151,13 +153,17 @@ function SearchPageNarrow({
const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()}));
- const handleSearchAction = useCallback((value: SearchParams | string) => {
- if (typeof value === 'string') {
- searchInServer(value);
- } else {
- search(value)?.then((jsonCode) => setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)));
- }
- }, []);
+ const handleSearchAction = useCallback(
+ (value: SearchParams | string) => {
+ if (typeof value === 'string') {
+ searchInServer(value);
+ } else {
+ setSearchRequestResponseStatusCode(null);
+ search(value)?.then((jsonCode) => setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)));
+ }
+ },
+ [setSearchRequestResponseStatusCode],
+ );
const navigation = useNavigation();
// When pre-inserted behind the RHP (not focused), always start in static rendering
diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
index 66bc4b37af6b..e6a0b1172ce0 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -31,7 +31,7 @@ function SpendOverTimeSectionContent() {
const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']);
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {query, queryJSON, groupBy, view, sortedData, state} = useSpendOverTimeData();
+ const {query, queryJSON, groupBy, view, sortedData, state, retry} = useSpendOverTimeData();
if (!queryJSON || !view || !groupBy || view === CONST.SEARCH.VIEW.TABLE || state === SPEND_OVER_TIME_STATE.HIDDEN) {
return null;
@@ -79,8 +79,10 @@ function SpendOverTimeSectionContent() {
titleStyles={[styles.mt0, styles.mb2]}
subtitle={translate('errorPage.subtitle')}
subtitleStyle={styles.textSupporting}
- containerStyle={[{minHeight: CHART_CONTENT_MIN_HEIGHT}, styles.gap5]}
+ containerStyle={[{minHeight: CHART_CONTENT_MIN_HEIGHT}, styles.gap5, styles.pb5]}
contentFitImage="contain"
+ buttonTranslationKey="common.tryAgain"
+ onButtonPress={retry}
/>
)}
{(state === SPEND_OVER_TIME_STATE.LOADING || state === SPEND_OVER_TIME_STATE.READY) && (
diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts
index 4138feea0aa6..ab633b7849c5 100644
--- a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts
+++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts
@@ -65,7 +65,7 @@ function useSpendOverTimeData() {
const {isOffline} = useNetwork();
const isFocused = useIsFocused();
- const onConfigChanged = useEffectEvent(() => {
+ const retry = () => {
// `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;
@@ -79,6 +79,10 @@ function useSpendOverTimeData() {
isLoading: false,
shouldUpdateLastSearchParams: false,
});
+ };
+
+ const onConfigChanged = useEffectEvent(() => {
+ retry();
});
useEffect(() => {
@@ -123,6 +127,7 @@ function useSpendOverTimeData() {
view,
sortedData,
state,
+ retry,
};
}