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
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions src/components/BlockingViews/BlockingView.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import Button from '@components/ButtonComposed';
import Icon from '@components/Icon';
import Lottie from '@components/Lottie';
import type DotLottieAnimation from '@components/LottieAnimations/types';
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';
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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -110,6 +119,8 @@ function BlockingView({
subtitleStyle,
linkTranslationKey,
subtitleKeyBelowLink,
buttonTranslationKey,
onButtonPress,
iconWidth = variables.iconSizeSuperLarge,
iconHeight = variables.iconSizeSuperLarge,
onLinkPress = () => Navigation.dismissModal(),
Expand All @@ -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]),
Expand Down Expand Up @@ -185,6 +197,14 @@ function BlockingView({
</SubtitleWrapper>
)}
</View>
{!!onButtonPress && !!buttonTranslationKey && (

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.

❌ CLEAN-REACT-PATTERNS-1 (docs)

This adds two new optional content props (buttonTranslationKey, onButtonPress) to BlockingView whose sole purpose is to conditionally render a UI element. Both props are used only inside this {!!onButtonPress && !!buttonTranslationKey && (<Button .../>)} block — nothing else. Per Case 2, an optional prop that exists only to gate a {!!prop && <Element />} render should be a composable child instead. Every future addition (e.g. a second action, an icon on the button) would again require expanding BlockingView's prop interface and internals, increasing coupling and regression risk.

Prefer composition so BlockingView never has to change when a consumer needs a CTA. For example, expose the button as a child the consumer opts into rather than a prop pair:

// Consumer (e.g. FullPageErrorView / SpendOverTimeSectionContent) composes the action:
<BlockingView icon={...} title={...} subtitle={...}>
    <BlockingView.Action onPress={retry}>{translate('common.tryAgain')}</BlockingView.Action>
</BlockingView>

This keeps BlockingView stable and lets each consumer decide whether (and what) to render below the subtitle without threading buttonTranslationKey/onButtonPress through FullPageErrorView as pure pass-throughs.


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

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.

It's a legitimate, well-reasoned style point, but not a correctness bug — non-blocking for this PR.

Consistent with existing precedent. ButtonComposed (imported in this very file) explicitly documents itself as replacing "a large flat props list" with children composition, so the critique aligns with the direction the codebase is already heading, not just abstract preference.

But adopting it here is a bigger lift than it looks:

  • BlockingView needs a real children slot — it has none today. The return is a fixed ScrollView → icon/animation → title/subtitle → button, with no insertion point.
  • A new BlockingView.Action sub-component would need to be built.
  • FullPageErrorView would need to forward children instead of the two pass-through props (buttonTranslationKey, onButtonPress).
  • Both consumers (SpendOverTimeSectionContent.tsx, Search/index.tsx) would need to switch call syntax.

Scope risk: BlockingView/FullPageErrorView are used broadly across the app (dozens of consumers). Widening its public contract is the kind of change worth doing deliberately — not as a rider on an issue explicitly scoped as a "lightweight interim improvement," where a more holistic approach was called out as intentionally out of scope.

<Button
onPress={onButtonPress}
sentryLabel={CONST.SENTRY_LABEL.BLOCKING_VIEW.RETRY_BUTTON}
>
<Button.Text>{translate(buttonTranslationKey)}</Button.Text>
</Button>
)}
</ScrollView>
);
}
Expand Down
26 changes: 24 additions & 2 deletions src/components/BlockingViews/FullPageErrorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,9 +36,26 @@ type FullPageErrorViewProps = {
subtitleStyle?: StyleProp<TextStyle>;

containerStyle?: StyleProp<ViewStyle>;

/** 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']);

Expand All @@ -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}
/>
</View>
</ForceFullScreenView>
Expand Down
22 changes: 22 additions & 0 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

Put an empty line before the comment unless it’s on the first line of a block.

Suggested change
// Retrying a failed page always resets pagination to the first page, so totals eligibility
// Retrying a failed page always resets pagination to the first page, so totals eligibility

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2f3ee38 — added the blank line before the comment.

// 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<SelectionListHandle<SearchListItem> | null>(null);
Expand Down Expand Up @@ -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.

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.

Suggested change
// Retrying an invalid query won't help, so the retry button is only offered for other errors.
// Retrying an invalid query won't help, so the retry button is only offered for other errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Couldn't apply this one — the blank line sits between two JSX attributes (subtitle= and the comment), and oxfmt strips blank lines inside a JSX attribute list. I added it and re-ran npm run fmt, which removed it again, so committing it would just fail the Oxfmt CI check. The comment is left directly under the subtitle prop without the leading blank line.

{...(!isInvalidQuery && {
buttonTranslationKey: 'common.tryAgain',
Comment on lines +950 to +951

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Tie invalid-query detection to the page-level request

When a malformed search is submitted through searchInServer or opened directly, useSearchPageSetup fires the request without updating searchRequestResponseStatusCode; that state therefore remains null (or can retain the status from a previous query). Consequently isInvalidQuery is false and this spread displays a Retry button that only repeats the invalid request—or, with a stale invalid status, hides Retry for a later retriable failure. Persist or propagate the response code from the page-level request and associate it with the current query before deciding whether to render the button.

Useful? React with 👍 / 👎.

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.

@MelvinBot Please address this comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — this was a real gap. Fixed in 6ecc256.

Root cause: searchRequestResponseStatusCode was only maintained on the user-driven re-search path (handleSearchAction). The page-level fire in useSearchPageSetup.ts:76 — which runs on initial mount / deep link / query change — fired search() without capturing the resolved jsonCode, and the state was never reset when the query changed. So exactly the two failure modes you flagged:

  1. An invalid query opened directly left the status at nullisInvalidQuery false → a pointless Try again was shown that just repeats the invalid request.
  2. A stale INVALID_SEARCH_QUERY from a previous query hid Try again on a later retriable failure.

(The dedup in search() made this worse: whichever fire path won the in-flight dedup returned the promise, so the component-level .then was frequently skipped entirely — Search.ts:960-963.)

Fix: made useSearchPageSetup the single source of truth for the response code, so it's tied to the current query regardless of which path fires the request:

  • The page-level fire now captures the jsonCode: useSearchPageSetup.ts:92-94.
  • The code resets on query (hash) change, so a stale INVALID_SEARCH_QUERY can't leak into the next query: useSearchPageSetup.ts:44-51 (adjusted during render rather than in an effect, per the react-hooks/set-state-in-effect rule).
  • Both SearchPage (wide) and SearchPageNarrow now consume this one value/setter instead of keeping their own separate useState, so the user-driven re-search feeds the same source of truth: SearchPage.tsx:51, SearchPageNarrow/index.tsx:161-171.
Checks run
  • npm run fmt (oxfmt) — applied
  • npm run typecheck-tsgo — no new errors in the changed files (the react-native-nitro-fetch / ResultMetadata errors are pre-existing and unrelated)
  • npm run lint-changed — 0 errors on the changed files (the one remaining warning is the pre-existing grandfathered setIsSorting in SearchPage.tsx)
  • npm run react-compiler-compliance-check check on all three files — passes (exit 0)

I wasn't able to run the invalid-query / failing-network flow locally, so a quick functional confirm of both scenarios (invalid query → no Retry; retriable failure after a prior invalid query → Retry shown) would be great.

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.

Reviewing...

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,
});
},
})}
/>
</View>
);
Expand Down
29 changes: 26 additions & 3 deletions src/hooks/useSearchPageSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,7 +36,24 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | 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<number | null>(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;
Expand Down Expand Up @@ -74,7 +91,11 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | 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(() => {
Expand All @@ -87,6 +108,8 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | undefined) {
}
openSearch();
}, [isOffline, prevIsOffline]);

return {searchRequestResponseStatusCode, setSearchRequestResponseStatusCode};
}

export default useSearchPageSetup;
29 changes: 16 additions & 13 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function SearchPage({route}: SearchPageProps) {

const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState<SearchResults | undefined>(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
Expand Down Expand Up @@ -108,18 +108,19 @@ function SearchPage({route}: SearchPageProps) {
setIsSorting(false);
}, [currentSearchResults?.isLoading, isSorting, prevIsLoading]);

const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState<number | null>(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);
Expand All @@ -145,6 +146,8 @@ function SearchPage({route}: SearchPageProps) {
<SearchPageNarrow
queryJSON={currentSearchQueryJSON}
searchResults={searchResults}
searchRequestResponseStatusCode={searchRequestResponseStatusCode}
setSearchRequestResponseStatusCode={setSearchRequestResponseStatusCode}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
onSortPressedCallback={onSortPressedCallback}
searchOverlayContent={searchOverlayContent}
Expand Down
24 changes: 15 additions & 9 deletions src/pages/Search/SearchPageNarrow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ type SearchPageNarrowProps = {
queryJSON?: SearchQueryJSON;
searchResults?: SearchResults;
isMobileSelectionModeEnabled: boolean;
searchRequestResponseStatusCode: number | null;
setSearchRequestResponseStatusCode: (statusCode: number | null) => void;
onSortPressedCallback: () => void;
/** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */
searchOverlayContent: React.ReactNode;
Expand All @@ -73,6 +75,8 @@ function SearchPageNarrow({
queryJSON,
searchResults,
isMobileSelectionModeEnabled,
searchRequestResponseStatusCode,
setSearchRequestResponseStatusCode,
onSortPressedCallback,
searchOverlayContent,
onSearchContentReady,
Expand All @@ -96,8 +100,6 @@ function SearchPageNarrow({
const {saveScrollOffset} = useContext(ScrollOffsetContext);
const receiptDropTargetRef = useRef<View>(null);

const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState<number | null>(null);

const scrollOffset = useSharedValue(0);
const topBarOffset = useSharedValue<number>(StyleUtils.searchHeaderDefaultOffset);

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -79,6 +79,10 @@ function useSpendOverTimeData() {
isLoading: false,
shouldUpdateLastSearchParams: false,
});
};

const onConfigChanged = useEffectEvent(() => {
retry();
});

useEffect(() => {
Expand Down Expand Up @@ -123,6 +127,7 @@ function useSpendOverTimeData() {
view,
sortedData,
state,
retry,
};
}

Expand Down
Loading