Skip to content

Commit 0270797

Browse files
Merge branch 'main' into jakubkalinski0/Odometer_add_save_for_later_functionality
2 parents 1b1eb95 + d791c21 commit 0270797

25 files changed

Lines changed: 565 additions & 302 deletions

config/eslint/eslint.seatbelt.tsv

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@
222222
"../../src/libs/OptionsListUtils/index.ts" "rulesdir/no-onyx-connect" 3
223223
"../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2
224224
"../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 2
225-
"../../src/libs/PolicyUtils.ts" "rulesdir/no-onyx-connect" 1
226225
"../../src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts" "no-restricted-syntax" 2
227226
"../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 3
228227
"../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 17
@@ -348,7 +347,6 @@
348347
"../../src/pages/RoomMembersPage.tsx" "react-hooks/set-state-in-effect" 3
349348
"../../src/pages/ScheduleCall/ScheduleCallPage.tsx" "react-hooks/preserve-manual-memoization" 1
350349
"../../src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx" "react-hooks/set-state-in-effect" 1
351-
"../../src/pages/Search/SearchPage.tsx" "react-hooks/refs" 31
352350
"../../src/pages/Search/SearchPage.tsx" "react-hooks/set-state-in-effect" 1
353351
"../../src/pages/TransactionDuplicate/Confirmation.tsx" "react-hooks/refs" 12
354352
"../../src/pages/Travel/TravelUpgrade.tsx" "react-hooks/set-state-in-effect" 1

src/CONST/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7516,6 +7516,10 @@ const CONST = {
75167516

75177517
DOWNLOADS_PATH: '/Downloads',
75187518
DOWNLOADS_TIMEOUT: 5000,
7519+
7520+
// Max time (ms) to wait for a transaction thread report before falling back to renderable content.
7521+
SKELETON_LOADING_TIMEOUT_MS: 10000,
7522+
75197523
NEW_EXPENSIFY_PATH: '/New Expensify',
75207524
RECEIPTS_UPLOAD_PATH: '/Receipts-Upload',
75217525

src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
2626
import useWindowDimensions from '@hooks/useWindowDimensions';
2727
import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils';
2828
import DateUtils from '@libs/DateUtils';
29+
import {hasDeferredWrite} from '@libs/deferredLayoutWrite';
2930
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
3031
import {getAllNonDeletedTransactions, isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils';
3132
import Navigation from '@libs/Navigation/Navigation';
@@ -665,6 +666,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
665666
return numToRender || undefined;
666667
}, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]);
667668

669+
const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState;
670+
// hasDeferredWrite is non-reactive (reads a module-level Map, not tracked by React).
671+
// This is intentional: we only check on the initial render after the RHP dismisses.
672+
// Once the deferred write flushes and createTransaction runs, Onyx updates make
673+
// transactions non-empty, which drives the transition away from the skeleton.
674+
const isAwaitingDeferredTransaction = isReportEmpty && hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL);
675+
const showEmptyState = isReportEmpty && !isAwaitingDeferredTransaction;
676+
668677
if (!report) {
669678
return null;
670679
}
@@ -685,7 +694,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
685694
isActive={isFloatingMessageCounterVisible}
686695
onClick={scrollToBottomAndMarkReportAsRead}
687696
/>
688-
{isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? (
697+
{/* Exactly one of these three branches is active at a time:
698+
1. isAwaitingDeferredTransaction — skeleton while dismiss-first creates the transaction
699+
2. showEmptyState — genuinely empty report
700+
3. !isReportEmpty — report has data, render the FlatList */}
701+
{isAwaitingDeferredTransaction && <ReportActionsListLoadingSkeleton reasonAttributes={skeletonReasonAttributes} />}
702+
{showEmptyState && (
689703
<ScrollView contentContainerStyle={styles.flexGrow1}>
690704
<MoneyRequestViewReportFields
691705
report={report}
@@ -697,7 +711,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
697711
policy={policy}
698712
/>
699713
</ScrollView>
700-
) : (
714+
)}
715+
{!isReportEmpty && (
701716
<FlatListWithScrollKey
702717
initialNumToRender={initialNumToRender}
703718
accessibilityLabel={translate('sidebarScreen.listOfChatMessages')}

src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ import useThemeStyles from '@hooks/useThemeStyles';
2525
import {close} from '@libs/actions/Modal';
2626
import Navigation from '@libs/Navigation/Navigation';
2727
import {buildFilterQueryWithSortDefaults, isAmountFilterKey} from '@libs/SearchQueryUtils';
28-
import {FILTER_GROUP_MAP, FILTER_LABEL_MAP, filterValidHasValues, getMultiSelectFilterOptions, getSingleSelectFilterOptions, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils';
28+
import {
29+
FILTER_GROUP_MAP,
30+
FILTER_LABEL_MAP,
31+
filterValidHasValues,
32+
getMultiSelectFilterOptions,
33+
getSingleSelectFilterOptions,
34+
mapFiltersFormToLabelValueList,
35+
SKIPPED_SEARCH_FILTERS,
36+
} from '@libs/SearchUIUtils';
2937
import type {SearchFilter} from '@libs/SearchUIUtils';
3038
import CONST from '@src/CONST';
3139
import type {TranslationPaths} from '@src/languages/types';
@@ -53,18 +61,6 @@ type UseSearchFiltersBarResult = {
5361
translate: ReturnType<typeof useLocalize>['translate'];
5462
};
5563

56-
const SKIPPED_FILTERS = new Set<SearchAdvancedFiltersKey>([
57-
FILTER_KEYS.GROUP_BY,
58-
FILTER_KEYS.GROUP_CURRENCY,
59-
FILTER_KEYS.LIMIT,
60-
FILTER_KEYS.TYPE,
61-
FILTER_KEYS.VIEW,
62-
FILTER_KEYS.PAYER,
63-
FILTER_KEYS.ACTION,
64-
FILTER_KEYS.COLUMNS,
65-
FILTER_KEYS.KEYWORD,
66-
]);
67-
6864
function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) {
6965
return `Search-Filter-${filterKey}`;
7066
}
@@ -206,7 +202,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
206202
});
207203
};
208204

209-
const filters = mapFiltersFormToLabelValueList<FilterItem>(searchAdvancedFiltersForm, queryJSON.policyID, SKIPPED_FILTERS, translate, localeCompare, (filterKey) => {
205+
const filters = mapFiltersFormToLabelValueList<FilterItem>(searchAdvancedFiltersForm, queryJSON.policyID, SKIPPED_SEARCH_FILTERS, translate, localeCompare, (filterKey) => {
210206
const groupConfig = FILTER_GROUP_MAP[filterKey];
211207
if (groupConfig) {
212208
if (isAmountFilterKey(groupConfig.syntax)) {
@@ -430,4 +426,4 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
430426

431427
export default useSearchFiltersBar;
432428
export type {FilterItem};
433-
export {typeOptionsPoliciesSelector, SKIPPED_FILTERS};
429+
export {typeOptionsPoliciesSelector};

src/components/Search/index.tsx

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
isTransactionGroupListItemType,
5555
isTransactionListItemType,
5656
isTransactionReportGroupListItemType,
57+
isTransactionSearchType,
5758
shouldShowEmptyState,
5859
shouldShowYear as shouldShowYearUtil,
5960
} from '@libs/SearchUIUtils';
@@ -80,7 +81,6 @@ import {useSearchActionsContext, useSearchStateContext} from './SearchContext';
8081
import SearchList from './SearchList';
8182
import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types';
8283
import {SearchScopeProvider} from './SearchScopeProvider';
83-
import SearchStaticList from './SearchStaticList';
8484
import SearchTableHeader from './SearchTableHeader';
8585
import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';
8686

@@ -1424,14 +1424,21 @@ function Search({
14241424
searchResults?.data,
14251425
]);
14261426

1427-
const onLayout = useCallback(() => {
1427+
const onLayoutBase = useCallback(() => {
14281428
hasHadFirstLayout.current = true;
14291429
onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout');
14301430
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true});
1431-
handleSelectionListScroll(stableSortedData, searchListRef.current);
14321431
flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
1432+
}, [onDestinationVisible]);
1433+
1434+
// Deferred layout only needs the base work (no scroll handling, no content-ready signal).
1435+
const onDeferredLayout = onLayoutBase;
1436+
1437+
const onLayout = useCallback(() => {
1438+
onLayoutBase();
1439+
handleSelectionListScroll(stableSortedData, searchListRef.current);
14331440
onContentReady?.();
1434-
}, [handleSelectionListScroll, stableSortedData, onContentReady, onDestinationVisible]);
1441+
}, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]);
14351442

14361443
// Must be a ref, not state: cancelNavigationSpans is called during render
14371444
// (inside conditional returns), so using setState would trigger infinite re-renders.
@@ -1527,25 +1534,14 @@ function Search({
15271534
);
15281535

15291536
// When heavy work is deferred (e.g. during the RHP dismiss animation after
1530-
// submitting an expense), show a lightweight static list instead of the skeleton.
1531-
// This gives the user real-looking content during the animation while avoiding
1532-
// the expensive hooks and renders of the full Search component.
1533-
// Restricted to transaction-based search types (expense/invoice) because
1534-
// SearchStaticList only renders rows with a transactionID - non-transaction
1535-
// types (chat, task, report) would render empty/blank during the deferral.
1536-
const isTransactionSearchType = type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE;
1537-
if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType) {
1538-
return (
1539-
<SearchStaticList
1540-
searchResults={searchResults}
1541-
queryJSON={queryJSON}
1542-
shouldUseNarrowLayout={shouldUseNarrowLayout}
1543-
canSelectMultiple={canSelectMultiple}
1544-
columns={currentColumns}
1545-
contentContainerStyle={shouldUseNarrowLayout ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined}
1546-
onLayout={onLayout}
1547-
/>
1548-
);
1537+
// submitting an expense), skip the expensive render below. The ancestor
1538+
// SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList
1539+
// overlay that covers this component, so the user sees real-looking content.
1540+
// The minimal View fires onLayout to flush the deferred API write and set
1541+
// hasHadFirstLayout.
1542+
if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType(type)) {
1543+
// Zero-sized View - onLayout still fires on RN, which is all we need here.
1544+
return <View onLayout={onDeferredLayout} />;
15491545
}
15501546

15511547
// This is a performance optimization for the submit-expense->search path only.

src/hooks/useSearchOverlay.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {useFocusEffect} from '@react-navigation/native';
2+
import React, {useCallback, useEffect, useState} from 'react';
3+
import type {StyleProp, ViewStyle} from 'react-native';
4+
import {useSession} from '@components/OnyxListItemProvider';
5+
import SearchStaticList from '@components/Search/SearchStaticList';
6+
import type {SearchQueryJSON} from '@components/Search/types';
7+
import {hasDeferredWrite} from '@libs/deferredLayoutWrite';
8+
import Navigation from '@libs/Navigation/Navigation';
9+
import {isDefaultExpensesQuery} from '@libs/SearchQueryUtils';
10+
import {getColumnsToShow, getValidGroupBy, isTransactionSearchType} from '@libs/SearchUIUtils';
11+
import CONST from '@src/CONST';
12+
import ONYXKEYS from '@src/ONYXKEYS';
13+
import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm';
14+
import type {SearchResults} from '@src/types/onyx';
15+
import useOnyx from './useOnyx';
16+
17+
const OVERLAY_SAFETY_TIMEOUT_MS = 5000;
18+
19+
type UseSearchOverlayParams = {
20+
searchResults: SearchResults | undefined;
21+
queryJSON: SearchQueryJSON | undefined;
22+
shouldUseNarrowLayout: boolean;
23+
isMobileSelectionModeEnabled: boolean;
24+
currentSearchKey: string | undefined;
25+
/** FlatList content padding for narrow layout (accounts for filter bars). */
26+
contentContainerStyle?: StyleProp<ViewStyle>;
27+
};
28+
29+
type UseSearchOverlayResult = {
30+
searchOverlayContent: React.ReactNode;
31+
onSearchContentReady: () => void;
32+
/** Whether the overlay lifecycle is active (armed but not yet ready). */
33+
isOverlayActive: boolean;
34+
};
35+
36+
/**
37+
* Manages the SearchStaticList overlay shown above the Search content area
38+
* during expense-creation flows. The overlay is displayed when a deferred
39+
* write is pending or a fullscreen route has been pre-inserted under the RHP,
40+
* and dismissed once the real Search component signals readiness via
41+
* onContentReady, or after a safety timeout.
42+
*/
43+
function useSearchOverlay({
44+
searchResults,
45+
queryJSON,
46+
shouldUseNarrowLayout,
47+
isMobileSelectionModeEnabled,
48+
currentSearchKey,
49+
contentContainerStyle,
50+
}: UseSearchOverlayParams): UseSearchOverlayResult {
51+
const session = useSession();
52+
const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
53+
const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector});
54+
55+
const [isSearchReady, setIsSearchReady] = useState(() => !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP());
56+
57+
const onSearchContentReady = () => {
58+
setIsSearchReady(true);
59+
};
60+
61+
// Re-arm the overlay on focus when a new deferred write was registered
62+
// (e.g. a subsequent submit flow while Search stays mounted).
63+
useFocusEffect(
64+
useCallback(() => {
65+
if (!hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()) {
66+
return;
67+
}
68+
setIsSearchReady(false);
69+
}, []),
70+
);
71+
72+
useEffect(() => {
73+
if (isSearchReady) {
74+
return;
75+
}
76+
const id = setTimeout(() => setIsSearchReady(true), OVERLAY_SAFETY_TIMEOUT_MS);
77+
return () => clearTimeout(id);
78+
}, [isSearchReady]);
79+
80+
// When the overlay is dismissed, skip column computation and JSX creation.
81+
// The hook subscriptions (useSession, useOnyx) must remain unconditional per
82+
// rules-of-hooks, but the derived work below is the expensive part.
83+
if (isSearchReady) {
84+
return {searchOverlayContent: null, onSearchContentReady, isOverlayActive: false};
85+
}
86+
87+
const isTransaction = isTransactionSearchType(queryJSON?.type);
88+
const canSelectMultiple = isTransaction && (!shouldUseNarrowLayout || isMobileSelectionModeEnabled);
89+
90+
const validGroupBy = queryJSON ? getValidGroupBy(queryJSON.groupBy) : undefined;
91+
const shouldUseStrictDefaultExpenseColumns = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPENSES && !!queryJSON && isDefaultExpensesQuery(queryJSON);
92+
93+
const searchData = searchResults?.data;
94+
const overlayColumns = (() => {
95+
if (!searchData || !queryJSON) {
96+
return [];
97+
}
98+
return getColumnsToShow({
99+
currentAccountID: accountID,
100+
data: searchData,
101+
visibleColumns: visibleColumns ?? [],
102+
type: queryJSON.type,
103+
groupBy: validGroupBy,
104+
shouldUseStrictDefaultExpenseColumns,
105+
});
106+
})();
107+
108+
// Narrow layout gets the custom contentContainerStyle (accounts for filter bars);
109+
// wide layout uses SearchStaticList's own internal padding (styles.pb3).
110+
const searchOverlayContent =
111+
isTransaction && searchData && queryJSON ? (
112+
<SearchStaticList
113+
searchResults={searchResults}
114+
queryJSON={queryJSON}
115+
shouldUseNarrowLayout={shouldUseNarrowLayout}
116+
canSelectMultiple={canSelectMultiple}
117+
columns={overlayColumns}
118+
contentContainerStyle={shouldUseNarrowLayout ? contentContainerStyle : undefined}
119+
/>
120+
) : null;
121+
122+
return {searchOverlayContent, onSearchContentReady, isOverlayActive: true};
123+
}
124+
125+
export default useSearchOverlay;

0 commit comments

Comments
 (0)