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
4 changes: 4 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7512,6 +7512,10 @@ const CONST = {

DOWNLOADS_PATH: '/Downloads',
DOWNLOADS_TIMEOUT: 5000,

// Max time (ms) to wait for a transaction thread report before falling back to renderable content.
SKELETON_LOADING_TIMEOUT_MS: 10000,

NEW_EXPENSIFY_PATH: '/New Expensify',
RECEIPTS_UPLOAD_PATH: '/Receipts-Upload',

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils';
import DateUtils from '@libs/DateUtils';
import {hasDeferredWrite} from '@libs/deferredLayoutWrite';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getAllNonDeletedTransactions, isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils';
import Navigation from '@libs/Navigation/Navigation';
Expand Down Expand Up @@ -665,6 +666,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
return numToRender || undefined;
}, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]);

const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState;
// hasDeferredWrite is non-reactive (reads a module-level Map, not tracked by React).
// This is intentional: we only check on the initial render after the RHP dismisses.
// Once the deferred write flushes and createTransaction runs, Onyx updates make
// transactions non-empty, which drives the transition away from the skeleton.
const isAwaitingDeferredTransaction = isReportEmpty && hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL);
const showEmptyState = isReportEmpty && !isAwaitingDeferredTransaction;

if (!report) {
return null;
}
Expand All @@ -685,7 +694,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
isActive={isFloatingMessageCounterVisible}
onClick={scrollToBottomAndMarkReportAsRead}
/>
{isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? (
{/* Exactly one of these three branches is active at a time:
1. isAwaitingDeferredTransaction — skeleton while dismiss-first creates the transaction
2. showEmptyState — genuinely empty report
3. !isReportEmpty — report has data, render the FlatList */}
{isAwaitingDeferredTransaction && <ReportActionsListLoadingSkeleton reasonAttributes={skeletonReasonAttributes} />}
{showEmptyState && (
<ScrollView contentContainerStyle={styles.flexGrow1}>
<MoneyRequestViewReportFields
report={report}
Expand All @@ -697,7 +711,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
policy={policy}
/>
</ScrollView>
) : (
)}
{!isReportEmpty && (
<FlatListWithScrollKey
initialNumToRender={initialNumToRender}
accessibilityLabel={translate('sidebarScreen.listOfChatMessages')}
Expand Down
26 changes: 11 additions & 15 deletions src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {close} from '@libs/actions/Modal';
import Navigation from '@libs/Navigation/Navigation';
import {buildFilterQueryWithSortDefaults, isAmountFilterKey} from '@libs/SearchQueryUtils';
import {FILTER_GROUP_MAP, FILTER_LABEL_MAP, filterValidHasValues, getMultiSelectFilterOptions, getSingleSelectFilterOptions, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils';
import {
FILTER_GROUP_MAP,
FILTER_LABEL_MAP,
filterValidHasValues,
getMultiSelectFilterOptions,
getSingleSelectFilterOptions,
mapFiltersFormToLabelValueList,
SKIPPED_SEARCH_FILTERS,
} from '@libs/SearchUIUtils';
import type {SearchFilter} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
Expand Down Expand Up @@ -53,18 +61,6 @@ type UseSearchFiltersBarResult = {
translate: ReturnType<typeof useLocalize>['translate'];
};

const SKIPPED_FILTERS = new Set<SearchAdvancedFiltersKey>([
FILTER_KEYS.GROUP_BY,
FILTER_KEYS.GROUP_CURRENCY,
FILTER_KEYS.LIMIT,
FILTER_KEYS.TYPE,
FILTER_KEYS.VIEW,
FILTER_KEYS.PAYER,
FILTER_KEYS.ACTION,
FILTER_KEYS.COLUMNS,
FILTER_KEYS.KEYWORD,
]);

function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) {
return `Search-Filter-${filterKey}`;
}
Expand Down Expand Up @@ -206,7 +202,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
});
};

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

export default useSearchFiltersBar;
export type {FilterItem};
export {typeOptionsPoliciesSelector, SKIPPED_FILTERS};
export {typeOptionsPoliciesSelector};
42 changes: 19 additions & 23 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
isTransactionGroupListItemType,
isTransactionListItemType,
isTransactionReportGroupListItemType,
isTransactionSearchType,
shouldShowEmptyState,
shouldShowYear as shouldShowYearUtil,
} from '@libs/SearchUIUtils';
Expand All @@ -81,7 +82,6 @@ import {useSearchActionsContext, useSearchStateContext} from './SearchContext';
import SearchList from './SearchList';
import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types';
import {SearchScopeProvider} from './SearchScopeProvider';
import SearchStaticList from './SearchStaticList';
import SearchTableHeader from './SearchTableHeader';
import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';

Expand Down Expand Up @@ -1425,14 +1425,21 @@ function Search({
searchResults?.data,
]);

const onLayout = useCallback(() => {
const onLayoutBase = useCallback(() => {
hasHadFirstLayout.current = true;
onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout');
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true});
handleSelectionListScroll(stableSortedData, searchListRef.current);
flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
}, [onDestinationVisible]);

// Deferred layout only needs the base work (no scroll handling, no content-ready signal).
const onDeferredLayout = onLayoutBase;

const onLayout = useCallback(() => {
onLayoutBase();
handleSelectionListScroll(stableSortedData, searchListRef.current);
onContentReady?.();
}, [handleSelectionListScroll, stableSortedData, onContentReady, onDestinationVisible]);
}, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]);

// Must be a ref, not state: cancelNavigationSpans is called during render
// (inside conditional returns), so using setState would trigger infinite re-renders.
Expand Down Expand Up @@ -1528,25 +1535,14 @@ function Search({
);

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

// This is a performance optimization for the submit-expense->search path only.
Expand Down
125 changes: 125 additions & 0 deletions src/hooks/useSearchOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {useSession} from '@components/OnyxListItemProvider';
import SearchStaticList from '@components/Search/SearchStaticList';
import type {SearchQueryJSON} from '@components/Search/types';
import {hasDeferredWrite} from '@libs/deferredLayoutWrite';
import Navigation from '@libs/Navigation/Navigation';
import {isDefaultExpensesQuery} from '@libs/SearchQueryUtils';
import {getColumnsToShow, getValidGroupBy, isTransactionSearchType} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm';
import type {SearchResults} from '@src/types/onyx';
import useOnyx from './useOnyx';

const OVERLAY_SAFETY_TIMEOUT_MS = 5000;

type UseSearchOverlayParams = {
searchResults: SearchResults | undefined;
Comment thread
JakubKorytko marked this conversation as resolved.
queryJSON: SearchQueryJSON | undefined;
shouldUseNarrowLayout: boolean;
isMobileSelectionModeEnabled: boolean;
currentSearchKey: string | undefined;
/** FlatList content padding for narrow layout (accounts for filter bars). */
contentContainerStyle?: StyleProp<ViewStyle>;
};

type UseSearchOverlayResult = {
searchOverlayContent: React.ReactNode;
Comment thread
JakubKorytko marked this conversation as resolved.
onSearchContentReady: () => void;
/** Whether the overlay lifecycle is active (armed but not yet ready). */
isOverlayActive: boolean;
};

/**
* Manages the SearchStaticList overlay shown above the Search content area
* during expense-creation flows. The overlay is displayed when a deferred
* write is pending or a fullscreen route has been pre-inserted under the RHP,
* and dismissed once the real Search component signals readiness via
* onContentReady, or after a safety timeout.
*/
function useSearchOverlay({
searchResults,
queryJSON,
shouldUseNarrowLayout,
isMobileSelectionModeEnabled,
currentSearchKey,
contentContainerStyle,
}: UseSearchOverlayParams): UseSearchOverlayResult {
const session = useSession();
const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector});

const [isSearchReady, setIsSearchReady] = useState(() => !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP());
Comment thread
JakubKorytko marked this conversation as resolved.

const onSearchContentReady = () => {
setIsSearchReady(true);
};

// Re-arm the overlay on focus when a new deferred write was registered
// (e.g. a subsequent submit flow while Search stays mounted).
useFocusEffect(
useCallback(() => {
if (!hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()) {
return;
}
setIsSearchReady(false);
}, []),
);

useEffect(() => {
if (isSearchReady) {
return;
}
const id = setTimeout(() => setIsSearchReady(true), OVERLAY_SAFETY_TIMEOUT_MS);
return () => clearTimeout(id);
}, [isSearchReady]);

// When the overlay is dismissed, skip column computation and JSX creation.
// The hook subscriptions (useSession, useOnyx) must remain unconditional per
// rules-of-hooks, but the derived work below is the expensive part.
if (isSearchReady) {
return {searchOverlayContent: null, onSearchContentReady, isOverlayActive: false};
}

const isTransaction = isTransactionSearchType(queryJSON?.type);
const canSelectMultiple = isTransaction && (!shouldUseNarrowLayout || isMobileSelectionModeEnabled);

const validGroupBy = queryJSON ? getValidGroupBy(queryJSON.groupBy) : undefined;
const shouldUseStrictDefaultExpenseColumns = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPENSES && !!queryJSON && isDefaultExpensesQuery(queryJSON);

const searchData = searchResults?.data;
const overlayColumns = (() => {
if (!searchData || !queryJSON) {
return [];
}
return getColumnsToShow({
currentAccountID: accountID,
data: searchData,
visibleColumns: visibleColumns ?? [],
type: queryJSON.type,
groupBy: validGroupBy,
shouldUseStrictDefaultExpenseColumns,
});
})();

// Narrow layout gets the custom contentContainerStyle (accounts for filter bars);
// wide layout uses SearchStaticList's own internal padding (styles.pb3).
const searchOverlayContent =
isTransaction && searchData && queryJSON ? (
<SearchStaticList
Comment thread
JakubKorytko marked this conversation as resolved.
Comment thread
JakubKorytko marked this conversation as resolved.
searchResults={searchResults}
queryJSON={queryJSON}
shouldUseNarrowLayout={shouldUseNarrowLayout}
canSelectMultiple={canSelectMultiple}
columns={overlayColumns}
contentContainerStyle={shouldUseNarrowLayout ? contentContainerStyle : undefined}
/>
) : null;

return {searchOverlayContent, onSearchContentReady, isOverlayActive: true};
}

export default useSearchOverlay;
6 changes: 5 additions & 1 deletion src/libs/MoneyRequestReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt
import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList';
import CONST from '@src/CONST';
import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportLoadingState, Transaction} from '@src/types/onyx';
import {hasDeferredWrite} from './deferredLayoutWrite';
import {isPaidGroupPolicy} from './PolicyUtils';
import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isDeletedParentAction, isMoneyRequestAction} from './ReportActionsUtils';
import {
Expand Down Expand Up @@ -136,7 +137,10 @@ function shouldWaitForTransactions(report: OnyxEntry<Report>, transactions: Tran

const isTransactionDataReady = transactions !== undefined;
const isTransactionThreadView = isReportTransactionThread(report);
const isStillLoadingData = transactions?.length === 0 && ((!!reportLoadingState?.isLoadingInitialReportActions && !reportLoadingState.hasOnceLoadedReportActions) || report?.total !== 0);
const hasPendingDismissWrite = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL);
const isStillLoadingData =
transactions?.length === 0 &&
((!!reportLoadingState?.isLoadingInitialReportActions && !reportLoadingState.hasOnceLoadedReportActions) || report?.total !== 0 || hasPendingDismissWrite);
return (
(isMoneyRequestReport(report) || isInvoiceReport(report)) &&
(!isTransactionDataReady || isStillLoadingData) &&
Expand Down
18 changes: 18 additions & 0 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,18 @@ type GetSectionsParams = {
*/
const GENERIC_SEARCH_KEYS: ReadonlySet<SearchKey> = new Set([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS]);

const SKIPPED_SEARCH_FILTERS = new Set<SearchAdvancedFiltersKey>([
FILTER_KEYS.GROUP_BY,
FILTER_KEYS.GROUP_CURRENCY,
FILTER_KEYS.LIMIT,
Comment thread
JakubKorytko marked this conversation as resolved.
FILTER_KEYS.TYPE,
FILTER_KEYS.VIEW,
FILTER_KEYS.PAYER,
FILTER_KEYS.ACTION,
FILTER_KEYS.COLUMNS,
FILTER_KEYS.KEYWORD,
]);

function doesSearchItemMatchSort(key: SearchKey, itemSortBy: string | undefined, itemSortOrder: string | undefined, currentSortBy: string | undefined, currentSortOrder: string | undefined) {
return GENERIC_SEARCH_KEYS.has(key) || (itemSortBy === currentSortBy && itemSortOrder === currentSortOrder);
}
Expand Down Expand Up @@ -4182,6 +4194,10 @@ function isCorrectSearchUserName(displayName?: string) {
return displayName && displayName.toUpperCase() !== CONST.REPORT.OWNER_EMAIL_FAKE;
}

function isTransactionSearchType(type: string | undefined): boolean {
return type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE;
}

function isTodoSearch(recentSearchHash: number, suggestedSearches: Record<string, SearchTypeMenuItem>) {
const matchedSearchKey = Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key;
return !!matchedSearchKey && TODO_SEARCH_KEYS.has(matchedSearchKey);
Expand Down Expand Up @@ -5842,6 +5858,8 @@ export {
FILTER_LABEL_MAP,
doesSearchItemMatchSort,
isPolicyEligibleForSpendOverTime,
isTransactionSearchType,
SKIPPED_SEARCH_FILTERS,
};
export type {
SavedSearchMenuItem,
Expand Down
3 changes: 2 additions & 1 deletion src/libs/telemetry/middlewares/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import type {EventHint, Log, TransactionEvent} from '@sentry/core';
import copyTagsToChildSpans from './copyTagsToChildSpans';
import emailDomainFilter from './emailDomainFilter';
import httpClientCancelledFilter from './httpClientCancelledFilter';
import maxDurationFilter from './maxDurationFilter';
import minDurationFilter from './minDurationFilter';
import onyxLogFilter from './onyxLogFilter';

type TelemetryBeforeSend = (event: TransactionEvent, hint: EventHint) => TransactionEvent | null | Promise<TransactionEvent | null>;
type TelemetryBeforeSendLog = (log: Log) => Log | null;

const middlewares: TelemetryBeforeSend[] = [emailDomainFilter, minDurationFilter, httpClientCancelledFilter, copyTagsToChildSpans];
const middlewares: TelemetryBeforeSend[] = [emailDomainFilter, minDurationFilter, maxDurationFilter, httpClientCancelledFilter, copyTagsToChildSpans];
const logMiddlewares: TelemetryBeforeSendLog[] = [onyxLogFilter];

function processBeforeSendTransactions(event: TransactionEvent, hint: EventHint): Promise<TransactionEvent | null> {
Expand Down
Loading
Loading