From 5a8fa53214088c45eeae5d3e43ac624053b1f034 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 24 Apr 2026 13:43:21 +0200 Subject: [PATCH 01/17] fix: search dismiss --- .../request/step/confirmation/getSubmitHandler.ts | 8 ++++---- tests/unit/getSubmitHandlerTest.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts index 66181e205381..4a8ed6c758be 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts @@ -61,8 +61,8 @@ function canUseDismissModalFastPath(snapshot: SubmitNavigationSnapshot): boolean * isPreInserted && !isReportPreInserted -> SEARCH_PRE_INSERT * isReportPreInserted -> REPORT_PRE_INSERT * canUseDismissModalFastPath() -> DISMISS_MODAL - * isReportInRHP && destinationReportID -> REPORT_IN_RHP_DISMISS * isFromGlobalCreate && canDismiss -> SEARCH_DISMISS + * isReportInRHP && destinationReportID -> REPORT_IN_RHP_DISMISS * else -> DEFAULT */ function getSubmitHandler(snapshot: SubmitNavigationSnapshot): SubmitHandler { @@ -75,12 +75,12 @@ function getSubmitHandler(snapshot: SubmitNavigationSnapshot): SubmitHandler { if (canUseDismissModalFastPath(snapshot)) { return SUBMIT_HANDLER.DISMISS_MODAL; } - if (snapshot.isReportInRHP && snapshot.destinationReportID && !snapshot.isSplitRequest) { - return SUBMIT_HANDLER.REPORT_IN_RHP_DISMISS; - } if (snapshot.isFromGlobalCreate && snapshot.canDismissFromSearch && snapshot.isSearchTopmostFullScreen) { return SUBMIT_HANDLER.SEARCH_DISMISS; } + if (snapshot.isReportInRHP && snapshot.destinationReportID && !snapshot.isSplitRequest) { + return SUBMIT_HANDLER.REPORT_IN_RHP_DISMISS; + } return SUBMIT_HANDLER.DEFAULT; } diff --git a/tests/unit/getSubmitHandlerTest.ts b/tests/unit/getSubmitHandlerTest.ts index 5503bc4868af..a08845d3662c 100644 --- a/tests/unit/getSubmitHandlerTest.ts +++ b/tests/unit/getSubmitHandlerTest.ts @@ -116,6 +116,20 @@ describe('getSubmitHandler', () => { ).toBe(SUBMIT_HANDLER.DEFAULT); }); + it('returns SEARCH_DISMISS (not REPORT_IN_RHP_DISMISS) for global create from Search with report in RHP', () => { + expect( + getSubmitHandler( + snap({ + isFromGlobalCreate: true, + canDismissFromSearch: true, + isSearchTopmostFullScreen: true, + isReportInRHP: true, + destinationReportID: '456', + }), + ), + ).toBe(SUBMIT_HANDLER.SEARCH_DISMISS); + }); + it('returns DEFAULT when destination report is set but not loaded', () => { expect( getSubmitHandler( From 72f5346961f245679076b0fb86ea55f26420318d Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 24 Apr 2026 14:53:08 +0200 Subject: [PATCH 02/17] fix: empty report in between --- src/pages/inbox/report/ReportActionItemCreated.tsx | 3 ++- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index fd09bd3eb800..6914b2d69c2b 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Navigation from '@libs/Navigation/Navigation'; import {isChatReport, isCurrentUserInvoiceReceiver, isInvoiceRoom, navigateToDetailsPage, shouldDisableDetailPage as shouldDisableDetailPageReportUtils} from '@libs/ReportUtils'; import {clearCreateChatError} from '@userActions/Report'; @@ -52,7 +53,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas, isSelfTourViewed)} > - + {!hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL) && } Date: Fri, 24 Apr 2026 16:48:44 +0200 Subject: [PATCH 03/17] fix: search flickering --- src/components/Search/index.tsx | 30 ++++------ src/pages/Search/SearchPage.tsx | 54 +++++++++++++++++ src/pages/Search/SearchPageNarrow/index.tsx | 42 +++++++------- src/pages/Search/SearchPageWide.tsx | 64 ++++++++++++--------- 4 files changed, 122 insertions(+), 68 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 25822f93c1d0..06f89eac91bf 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -79,7 +79,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'; @@ -1426,6 +1425,13 @@ function Search({ searchResults?.data, ]); + const onDeferredLayout = useCallback(() => { + hasHadFirstLayout.current = true; + onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + }, [onDestinationVisible]); + const onLayout = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); @@ -1529,25 +1535,13 @@ 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. + // submitting an expense), skip the expensive render below. The parent + // (SearchPage) 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. const isTransactionSearchType = type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE; if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType) { - return ( - - ); + return ; } // This is a performance optimization for the submit-expense->search path only. diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index a235f2ddbee9..7a5545a9aad8 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,12 +1,15 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import Animated from 'react-native-reanimated'; +import {useSession} from '@components/OnyxListItemProvider'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchParams} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useConfirmReadyToOpenApp from '@hooks/useConfirmReadyToOpenApp'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchPageSetup from '@hooks/useSearchPageSetup'; @@ -14,10 +17,16 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import useThemeStyles from '@hooks/useThemeStyles'; import {searchInServer} from '@libs/actions/Report'; import {search} from '@libs/actions/Search'; +import {hasDeferredWrite} from '@libs/deferredLayoutWrite'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; +import {isDefaultExpensesQuery} from '@libs/SearchQueryUtils'; +import {getColumnsToShow, getValidGroupBy} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {SearchResults} from '@src/types/onyx'; import SearchPageNarrow from './SearchPageNarrow'; import SearchPageWide from './SearchPageWide'; @@ -33,6 +42,9 @@ function SearchPage({route}: SearchPageProps) { const {clearSelectedTransactions, setLastSearchType} = useSearchActionsContext(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); + const session = useSession(); + const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const lastNonEmptySearchResults = useRef(undefined); @@ -130,6 +142,44 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(true); }, []); + // Overlay: a single SearchStaticList rendered above the content area. + // Shown when an expense-creation flow pre-inserts this page or reserves + // a deferred write. Dismissed once Search signals readiness (onContentReady). + const [isSearchReady, setIsSearchReady] = useState(() => !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()); + const onSearchContentReady = useCallback(() => { + setIsSearchReady(true); + }, []); + + const isTransactionSearchType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE || currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.INVOICE; + const canSelectMultiple = isTransactionSearchType && (!shouldUseNarrowLayout || isMobileSelectionModeEnabled); + + const validGroupBy = currentSearchQueryJSON ? getValidGroupBy(currentSearchQueryJSON.groupBy) : undefined; + const shouldUseStrictDefaultExpenseColumns = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPENSES && !!currentSearchQueryJSON && isDefaultExpensesQuery(currentSearchQueryJSON); + const overlayColumns = useMemo(() => { + if (!searchResults?.data || !currentSearchQueryJSON) { + return []; + } + return getColumnsToShow({ + currentAccountID: accountID, + data: searchResults.data, + visibleColumns: visibleColumns ?? [], + type: currentSearchQueryJSON.type, + groupBy: validGroupBy, + shouldUseStrictDefaultExpenseColumns, + }); + }, [accountID, searchResults?.data, currentSearchQueryJSON, visibleColumns, validGroupBy, shouldUseStrictDefaultExpenseColumns]); + + const searchOverlayContent = + !isSearchReady && isTransactionSearchType && !!searchResults?.data && currentSearchQueryJSON ? ( + + ) : null; + return ( {shouldUseNarrowLayout ? ( @@ -141,6 +191,8 @@ function SearchPage({route}: SearchPageProps) { footerData={footerData} shouldShowFooter={shouldShowFooter} onSortPressedCallback={onSortPressedCallback} + searchOverlayContent={searchOverlayContent} + onSearchContentReady={onSearchContentReady} /> ) : ( )} diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index a13784387d7f..e7e8ab283683 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -19,7 +19,6 @@ import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPageHeaderNarrow'; import {SKIPPED_FILTERS} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; -import SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -66,6 +65,8 @@ type SearchPageNarrowProps = { }; shouldShowFooter: boolean; onSortPressedCallback: () => void; + searchOverlayContent: React.ReactNode; + onSearchContentReady: () => void; }; function hasFilterBarsSelector(searchAdvancedFiltersForm: OnyxEntry) { @@ -75,7 +76,17 @@ function hasFilterBarsSelector(searchAdvancedFiltersForm: OnyxEntry; -function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter, onSortPressedCallback}: SearchPageNarrowProps) { +function SearchPageNarrow({ + queryJSON, + searchResults, + isMobileSelectionModeEnabled, + metadata, + footerData, + shouldShowFooter, + onSortPressedCallback, + searchOverlayContent, + onSearchContentReady, +}: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -189,7 +200,6 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); - const [isSearchReady, setIsSearchReady] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); const [, startTransition] = useTransition(); useEffect(() => { @@ -204,10 +214,6 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable }); }, [startTransition]); - const onSearchContentReady = useCallback(() => { - setIsSearchReady(true); - }, []); - const endSubmitNavigationSpans = useEndSubmitNavigationSpans({requireLayout: true}); // Wait for focus before transitioning to the full interactive Search component. @@ -246,12 +252,6 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable const shouldShowLoadingState = !isOffline && (!isDataLoaded || !!metadata?.isLoading); const contentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(hasFilterBars) : undefined; - // Overlay pattern: SearchStaticList renders as an absolute-fill sibling on - // top of Search, so its native views are never unmounted by a tree-structure - // swap. Search mounts behind the overlay when isInteractive flips, and once - // Search signals readiness (onContentReady -> onLayout), the overlay is removed. - const showStaticOverlay = useStaticRendering && !isSearchReady; - const renderStaticSearchList = () => ( <> {isInteractive && ( @@ -269,15 +269,12 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable hasFilterBars={hasFilterBars} /> )} - {showStaticOverlay && ( - - + {!!searchOverlayContent && ( + + {searchOverlayContent} )} @@ -313,6 +310,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} searchRequestResponseStatusCode={searchRequestResponseStatusCode} onDestinationVisible={endSubmitNavigationSpans} + onContentReady={onSearchContentReady} hasFilterBars={hasFilterBars} /> ); diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 92ab04fbbd08..51f2671ca5e7 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useContext, useMemo, useRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; @@ -40,6 +40,8 @@ type SearchPageWideProps = { onSortPressedCallback: () => void; route: PlatformStackRouteProp; shouldShowFooter: boolean; + searchOverlayContent: React.ReactNode; + onSearchContentReady: () => void; }; function SearchPageWide({ @@ -52,6 +54,8 @@ function SearchPageWide({ onSortPressedCallback, route, shouldShowFooter, + searchOverlayContent, + onSearchContentReady, }: SearchPageWideProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const styles = useThemeStyles(); @@ -109,33 +113,37 @@ function SearchPageWide({ onSort={onSortPressedCallback} handleSearch={handleSearchAction} /> - {shouldShowLoadingSkeleton ? ( - 0 && !isOffline, - hasPendingResponse: searchRequestResponseStatusCode === null, - shouldUseLiveData, - }} - /> - ) : ( - - )} + + {shouldShowLoadingSkeleton ? ( + 0 && !isOffline, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, + }} + /> + ) : ( + + )} + {!!searchOverlayContent && {searchOverlayContent}} + {shouldShowFooter && ( Date: Fri, 24 Apr 2026 17:12:03 +0200 Subject: [PATCH 04/17] refactor: cleanup the code --- src/components/Search/index.tsx | 24 ++--- src/hooks/useSearchOverlay.tsx | 102 ++++++++++++++++++ src/libs/SearchUIUtils.ts | 5 + src/pages/Search/SearchPage.tsx | 59 +++------- src/pages/Search/SearchPageNarrow/index.tsx | 12 +-- .../inbox/report/ReportActionItemCreated.tsx | 4 + .../step/confirmation/getSubmitHandler.ts | 2 +- src/selectors/AdvancedSearchFiltersForm.ts | 12 ++- 8 files changed, 148 insertions(+), 72 deletions(-) create mode 100644 src/hooks/useSearchOverlay.tsx diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 06f89eac91bf..41ea4adab70c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -53,6 +53,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, + isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -1425,21 +1426,20 @@ function Search({ searchResults?.data, ]); - const onDeferredLayout = useCallback(() => { + const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); }, [onDestinationVisible]); + const onDeferredLayout = onLayoutBase; + const onLayout = useCallback(() => { - hasHadFirstLayout.current = true; - onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + onLayoutBase(); handleSelectionListScroll(stableSortedData, searchListRef.current); - flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); 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. @@ -1535,12 +1535,12 @@ function Search({ ); // When heavy work is deferred (e.g. during the RHP dismiss animation after - // submitting an expense), skip the expensive render below. The parent - // (SearchPage) 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. - const isTransactionSearchType = type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE; - if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType) { + // 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)) { return ; } diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx new file mode 100644 index 000000000000..e52ff5cd2478 --- /dev/null +++ b/src/hooks/useSearchOverlay.tsx @@ -0,0 +1,102 @@ +import React, {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; + queryJSON: SearchQueryJSON | undefined; + shouldUseNarrowLayout: boolean; + isMobileSelectionModeEnabled: boolean; + currentSearchKey: string | undefined; + /** FlatList content padding for narrow layout (accounts for filter bars). */ + contentContainerStyle?: StyleProp; +}; + +type UseSearchOverlayResult = { + searchOverlayContent: React.ReactNode; + onSearchContentReady: () => void; +}; + +/** + * 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()); + + const onSearchContentReady = () => { + setIsSearchReady(true); + }; + + useEffect(() => { + if (isSearchReady) { + return; + } + const id = setTimeout(() => setIsSearchReady(true), OVERLAY_SAFETY_TIMEOUT_MS); + return () => clearTimeout(id); + }, [isSearchReady]); + + 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, + }); + })(); + + const searchOverlayContent = + !isSearchReady && isTransaction && searchData && queryJSON ? ( + + ) : null; + + return {searchOverlayContent, onSearchContentReady}; +} + +export default useSearchOverlay; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 1f7cba67259a..93ba2ab5884d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4153,6 +4153,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) { const matchedSearchKey = Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key; return !!matchedSearchKey && TODO_SEARCH_KEYS.has(matchedSearchKey); @@ -5791,6 +5795,7 @@ export { FILTER_LABEL_MAP, doesSearchItemMatchSort, isPolicyEligibleForSpendOverTime, + isTransactionSearchType, }; export type { SavedSearchMenuItem, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7a5545a9aad8..1a8970c9d06c 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,8 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import Animated from 'react-native-reanimated'; -import {useSession} from '@components/OnyxListItemProvider'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; -import SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchParams} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useConfirmReadyToOpenApp from '@hooks/useConfirmReadyToOpenApp'; @@ -12,21 +10,18 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchOverlay from '@hooks/useSearchOverlay'; import useSearchPageSetup from '@hooks/useSearchPageSetup'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; import {searchInServer} from '@libs/actions/Report'; import {search} from '@libs/actions/Search'; -import {hasDeferredWrite} from '@libs/deferredLayoutWrite'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {isDefaultExpensesQuery} from '@libs/SearchQueryUtils'; -import {getColumnsToShow, getValidGroupBy} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {SearchResults} from '@src/types/onyx'; import SearchPageNarrow from './SearchPageNarrow'; import SearchPageWide from './SearchPageWide'; @@ -42,9 +37,7 @@ function SearchPage({route}: SearchPageProps) { const {clearSelectedTransactions, setLastSearchType} = useSearchActionsContext(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); - const session = useSession(); - const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const [hasFilterBars] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: hasFilterBarsSelector}); const lastNonEmptySearchResults = useRef(undefined); @@ -142,43 +135,15 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(true); }, []); - // Overlay: a single SearchStaticList rendered above the content area. - // Shown when an expense-creation flow pre-inserts this page or reserves - // a deferred write. Dismissed once Search signals readiness (onContentReady). - const [isSearchReady, setIsSearchReady] = useState(() => !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()); - const onSearchContentReady = useCallback(() => { - setIsSearchReady(true); - }, []); - - const isTransactionSearchType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE || currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.INVOICE; - const canSelectMultiple = isTransactionSearchType && (!shouldUseNarrowLayout || isMobileSelectionModeEnabled); - - const validGroupBy = currentSearchQueryJSON ? getValidGroupBy(currentSearchQueryJSON.groupBy) : undefined; - const shouldUseStrictDefaultExpenseColumns = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPENSES && !!currentSearchQueryJSON && isDefaultExpensesQuery(currentSearchQueryJSON); - const overlayColumns = useMemo(() => { - if (!searchResults?.data || !currentSearchQueryJSON) { - return []; - } - return getColumnsToShow({ - currentAccountID: accountID, - data: searchResults.data, - visibleColumns: visibleColumns ?? [], - type: currentSearchQueryJSON.type, - groupBy: validGroupBy, - shouldUseStrictDefaultExpenseColumns, - }); - }, [accountID, searchResults?.data, currentSearchQueryJSON, visibleColumns, validGroupBy, shouldUseStrictDefaultExpenseColumns]); - - const searchOverlayContent = - !isSearchReady && isTransactionSearchType && !!searchResults?.data && currentSearchQueryJSON ? ( - - ) : null; + const overlayContentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined; + const {searchOverlayContent, onSearchContentReady} = useSearchOverlay({ + searchResults, + queryJSON: currentSearchQueryJSON, + shouldUseNarrowLayout, + isMobileSelectionModeEnabled, + currentSearchKey, + contentContainerStyle: overlayContentContainerStyle, + }); return ( diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index e7e8ab283683..3ab2800b9b7e 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -1,7 +1,6 @@ import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -18,7 +17,6 @@ import {useSearchActionsContext, useSearchStateContext} from '@components/Search import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPageHeaderNarrow'; -import {SKIPPED_FILTERS} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -35,7 +33,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, shouldShowFilter} from '@libs/SearchUIUtils'; +import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import variables from '@styles/variables'; import {searchInServer} from '@userActions/Report'; @@ -43,8 +41,7 @@ import {search} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; +import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {SearchResults} from '@src/types/onyx'; import type {SearchResultsInfo} from '@src/types/onyx/SearchResults'; import {SearchActionsBarSwitch, SearchFiltersBarSwitch, SearchPageInputSwitch, SearchTypeMenuSwitch} from './Switches'; @@ -69,11 +66,6 @@ type SearchPageNarrowProps = { onSearchContentReady: () => void; }; -function hasFilterBarsSelector(searchAdvancedFiltersForm: OnyxEntry) { - const type = searchAdvancedFiltersForm?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; - return !!Object.entries(searchAdvancedFiltersForm ?? {}).filter(([key, value]) => shouldShowFilter(SKIPPED_FILTERS, key as SearchAdvancedFiltersKey, value, type)).length; -} - const tabBarContent = ; function SearchPageNarrow({ diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index 6914b2d69c2b..1d753469696e 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -53,6 +53,10 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas, isSelfTourViewed)} > + {/* hasDeferredWrite is non-reactive (reads a module-level Map, not tracked by React). + This is intentional: we only suppress the animation on the initial render while a + DISMISS_MODAL write is pending. The animation re-appears on the next organic + re-render (e.g. Onyx updates after the API write resolves). */} {!hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL) && } SEARCH_PRE_INSERT * isReportPreInserted -> REPORT_PRE_INSERT * canUseDismissModalFastPath() -> DISMISS_MODAL - * isFromGlobalCreate && canDismiss -> SEARCH_DISMISS + * isFromGlobalCreate && canDismissFromSearch && isSearchTopmostFullScreen -> SEARCH_DISMISS * isReportInRHP && destinationReportID -> REPORT_IN_RHP_DISMISS * else -> DEFAULT */ diff --git a/src/selectors/AdvancedSearchFiltersForm.ts b/src/selectors/AdvancedSearchFiltersForm.ts index 6ff050d3e7b6..49f9f09b3c37 100644 --- a/src/selectors/AdvancedSearchFiltersForm.ts +++ b/src/selectors/AdvancedSearchFiltersForm.ts @@ -1,7 +1,15 @@ import type {OnyxEntry} from 'react-native-onyx'; +import {SKIPPED_FILTERS} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; +import {shouldShowFilter} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; const columnsSelector = (form: OnyxEntry) => form?.columns; -// eslint-disable-next-line import/prefer-default-export -export {columnsSelector}; +const hasFilterBarsSelector = (form: OnyxEntry) => { + const type = form?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; + return !!Object.entries(form ?? {}).filter(([key, value]) => shouldShowFilter(SKIPPED_FILTERS, key as SearchAdvancedFiltersKey, value, type)).length; +}; + +export {columnsSelector, hasFilterBarsSelector}; From 9415615739520698a1576fca5dc70802ade4dcff Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 24 Apr 2026 18:27:05 +0200 Subject: [PATCH 05/17] fix: empty report state --- .../MoneyRequestReportActionsList.tsx | 19 ++++++++++++-- .../SearchPageHeader/useSearchFiltersBar.tsx | 25 ++++++++----------- src/components/Search/index.tsx | 2 ++ src/hooks/useSearchOverlay.tsx | 11 +++++++- src/libs/SearchUIUtils.ts | 12 +++++++++ src/pages/Search/SearchPage.tsx | 3 ++- src/pages/Search/SearchPageNarrow/index.tsx | 11 +++++--- src/pages/Search/SearchPageWide.tsx | 1 + .../SubmitExpenseOrchestrator.tsx | 20 ++++++++++++--- .../step/confirmation/getSubmitHandler.ts | 2 +- src/selectors/AdvancedSearchFiltersForm.ts | 5 ++-- 11 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 7e1410b799b3..a59f129bd560 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -27,6 +27,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'; @@ -667,6 +668,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; } @@ -687,7 +696,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 && } + {showEmptyState && ( - ) : ( + )} + {!isReportEmpty && ( ['translate']; }; -const SKIPPED_FILTERS = new Set([ - 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, -]); - function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) { return `Search-Filter-${filterKey}`; } @@ -191,7 +188,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes }); }; - const filters = mapFiltersFormToLabelValueList(searchAdvancedFiltersForm, queryJSON.policyID, SKIPPED_FILTERS, translate, localeCompare, (filterKey) => { + const filters = mapFiltersFormToLabelValueList(searchAdvancedFiltersForm, queryJSON.policyID, SKIPPED_SEARCH_FILTERS, translate, localeCompare, (filterKey) => { const groupConfig = FILTER_GROUP_MAP[filterKey]; if (groupConfig) { if (isAmountFilterKey(groupConfig.syntax)) { @@ -399,4 +396,4 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes export default useSearchFiltersBar; export type {FilterItem}; -export {typeOptionsPoliciesSelector, SKIPPED_FILTERS}; +export {typeOptionsPoliciesSelector}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 41ea4adab70c..76ddffd202a7 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1433,6 +1433,7 @@ function Search({ 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(() => { @@ -1541,6 +1542,7 @@ function Search({ // 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 ; } diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx index e52ff5cd2478..155374267985 100644 --- a/src/hooks/useSearchOverlay.tsx +++ b/src/hooks/useSearchOverlay.tsx @@ -63,6 +63,13 @@ function useSearchOverlay({ 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}; + } + const isTransaction = isTransactionSearchType(queryJSON?.type); const canSelectMultiple = isTransaction && (!shouldUseNarrowLayout || isMobileSelectionModeEnabled); @@ -84,8 +91,10 @@ function useSearchOverlay({ }); })(); + // Narrow layout gets the custom contentContainerStyle (accounts for filter bars); + // wide layout uses SearchStaticList's own internal padding (styles.pb3). const searchOverlayContent = - !isSearchReady && isTransaction && searchData && queryJSON ? ( + isTransaction && searchData && queryJSON ? ( = new Set([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS]); +const SKIPPED_SEARCH_FILTERS = new Set([ + 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, +]); + 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); } @@ -5796,6 +5807,7 @@ export { doesSearchItemMatchSort, isPolicyEligibleForSpendOverTime, isTransactionSearchType, + SKIPPED_SEARCH_FILTERS, }; export type { SavedSearchMenuItem, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1a8970c9d06c..d55df8dff642 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -37,7 +37,7 @@ function SearchPage({route}: SearchPageProps) { const {clearSelectedTransactions, setLastSearchType} = useSearchActionsContext(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); - const [hasFilterBars] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: hasFilterBarsSelector}); + const [hasFilterBars = false] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: hasFilterBarsSelector}); const lastNonEmptySearchResults = useRef(undefined); @@ -158,6 +158,7 @@ function SearchPage({route}: SearchPageProps) { onSortPressedCallback={onSortPressedCallback} searchOverlayContent={searchOverlayContent} onSearchContentReady={onSearchContentReady} + hasFilterBars={hasFilterBars} /> ) : ( void; + /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; onSearchContentReady: () => void; + hasFilterBars: boolean; }; const tabBarContent = ; @@ -78,6 +77,7 @@ function SearchPageNarrow({ onSortPressedCallback, searchOverlayContent, onSearchContentReady, + hasFilterBars, }: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); @@ -99,7 +99,6 @@ function SearchPageNarrow({ const receiptDropTargetRef = useRef(null); const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); - const [hasFilterBars = false] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: hasFilterBarsSelector}); const scrollOffset = useSharedValue(0); const topBarOffset = useSharedValue(StyleUtils.searchHeaderDefaultOffset); @@ -272,6 +271,10 @@ function SearchPageNarrow({ ); + // The overlay (searchOverlayContent) is intentionally omitted here. + // The dynamic path runs outside submit-and-navigate flows, where Search's + // own skeleton/loading states handle transitions. On wide layout (SearchPageWide), + // the overlay is always rendered because wide doesn't have this static/dynamic split. const renderDynamicSearchList = () => { if (shouldShowLoadingSkeleton) { return ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 51f2671ca5e7..021ec9af989e 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -40,6 +40,7 @@ type SearchPageWideProps = { onSortPressedCallback: () => void; route: PlatformStackRouteProp; shouldShowFooter: boolean; + /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; onSearchContentReady: () => void; }; diff --git a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx index 0d45b5c1bcf3..21af166e84e5 100644 --- a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx +++ b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx @@ -398,14 +398,28 @@ function SubmitExpenseOrchestrator({ }); }; - // Unlike handleDismissModalFastPath, this handler does NOT call reserveDeferredWriteChannel. - // The createTransaction call runs inside runAfterDismiss (after the transition completes), - // so there is no animation to protect - the write can execute immediately via deferOrExecuteWrite. + // The createTransaction call runs inside runAfterDismiss (after the transition completes). + // When the destination report is empty we reserve a DISMISS_MODAL deferred-write channel + // so that MoneyRequestReportActionsList can show a loading skeleton instead of the + // "no expenses" empty state while the dismiss animation plays. const handleReportInRHPDismiss = (listOfParticipants: Participant[]) => { setFastPath(CONST.TELEMETRY.FAST_PATH_HANDLER.REPORT_IN_RHP_DISMISS, CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DISMISS_FIRST); const rootState = navigationRef.getRootState(); + const report = destinationReportID ? getReportOrDraftReport(destinationReportID) : undefined; + const isDestinationEmpty = !isMoneyRequestReport(report) || report?.transactionCount === 0; + if (isDestinationEmpty) { + reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); + } + const runAfterDismiss = () => { + // Flush signals readiness on the reserved channel. Since no real write was + // registered, the channel transitions to flushRequested. When createTransaction + // below calls deferOrExecuteWrite, it sees the flushed channel and executes + // the write immediately instead of deferring. + if (isDestinationEmpty) { + flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); + } createTransaction(listOfParticipants, false, false); setIsConfirming(false); }; diff --git a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts index 6dc8a6f51e25..e9360c2e37b2 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts @@ -62,7 +62,7 @@ function canUseDismissModalFastPath(snapshot: SubmitNavigationSnapshot): boolean * isReportPreInserted -> REPORT_PRE_INSERT * canUseDismissModalFastPath() -> DISMISS_MODAL * isFromGlobalCreate && canDismissFromSearch && isSearchTopmostFullScreen -> SEARCH_DISMISS - * isReportInRHP && destinationReportID -> REPORT_IN_RHP_DISMISS + * isReportInRHP && destinationReportID && !isSplitRequest -> REPORT_IN_RHP_DISMISS * else -> DEFAULT */ function getSubmitHandler(snapshot: SubmitNavigationSnapshot): SubmitHandler { diff --git a/src/selectors/AdvancedSearchFiltersForm.ts b/src/selectors/AdvancedSearchFiltersForm.ts index 49f9f09b3c37..dc33dca35e0a 100644 --- a/src/selectors/AdvancedSearchFiltersForm.ts +++ b/src/selectors/AdvancedSearchFiltersForm.ts @@ -1,6 +1,5 @@ import type {OnyxEntry} from 'react-native-onyx'; -import {SKIPPED_FILTERS} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; -import {shouldShowFilter} from '@libs/SearchUIUtils'; +import {shouldShowFilter, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; @@ -9,7 +8,7 @@ const columnsSelector = (form: OnyxEntry) => form?.co const hasFilterBarsSelector = (form: OnyxEntry) => { const type = form?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; - return !!Object.entries(form ?? {}).filter(([key, value]) => shouldShowFilter(SKIPPED_FILTERS, key as SearchAdvancedFiltersKey, value, type)).length; + return Object.entries(form ?? {}).some(([key, value]) => shouldShowFilter(SKIPPED_SEARCH_FILTERS, key as SearchAdvancedFiltersKey, value, type)); }; export {columnsSelector, hasFilterBarsSelector}; From 8b025f5a205f481bfc2f6ddf240f85768d4dfcbe Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 24 Apr 2026 20:42:23 +0200 Subject: [PATCH 06/17] address codex comments --- src/hooks/useSearchOverlay.tsx | 14 ++++- src/libs/MoneyRequestReportUtils.ts | 5 +- src/pages/Search/SearchPageNarrow/index.tsx | 54 ++++++++++--------- src/pages/inbox/report/ReportActionsView.tsx | 11 +++- .../step/IOURequestStepConfirmation.tsx | 3 +- 5 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx index 155374267985..d11bc5020de9 100644 --- a/src/hooks/useSearchOverlay.tsx +++ b/src/hooks/useSearchOverlay.tsx @@ -1,4 +1,5 @@ -import React, {useEffect, useState} from 'react'; +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'; @@ -55,6 +56,17 @@ function useSearchOverlay({ 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; diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 70c4d318fe17..6205e715bd8f 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -4,6 +4,7 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import CONST from '@src/CONST'; import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportMetadata, Transaction} from '@src/types/onyx'; import {convertToDisplayString} from './CurrencyUtils'; +import {hasDeferredWrite} from './deferredLayoutWrite'; import {isPaidGroupPolicy} from './PolicyUtils'; import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isDeletedParentAction, isMoneyRequestAction} from './ReportActionsUtils'; import { @@ -136,7 +137,9 @@ function shouldWaitForTransactions(report: OnyxEntry, transactions: Tran const isTransactionDataReady = transactions !== undefined; const isTransactionThreadView = isReportTransactionThread(report); - const isStillLoadingData = transactions?.length === 0 && ((!!reportMetadata?.isLoadingInitialReportActions && !reportMetadata.hasOnceLoadedReportActions) || report?.total !== 0); + const hasPendingDismissWrite = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); + const isStillLoadingData = + transactions?.length === 0 && ((!!reportMetadata?.isLoadingInitialReportActions && !reportMetadata.hasOnceLoadedReportActions) || report?.total !== 0 || hasPendingDismissWrite); return ( (isMoneyRequestReport(report) || isInvoiceReport(report)) && (!isTransactionDataReady || isStillLoadingData) && diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index fffc4b59f919..88d8ad4eb62b 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -271,13 +271,9 @@ function SearchPageNarrow({ ); - // The overlay (searchOverlayContent) is intentionally omitted here. - // The dynamic path runs outside submit-and-navigate flows, where Search's - // own skeleton/loading states handle transitions. On wide layout (SearchPageWide), - // the overlay is always rendered because wide doesn't have this static/dynamic split. - const renderDynamicSearchList = () => { - if (shouldShowLoadingSkeleton) { - return ( + const renderDynamicSearchList = () => ( + <> + {shouldShowLoadingSkeleton ? ( - ); - } - - return ( - - ); - }; + ) : ( + + )} + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + ); return ( { // update ref with current state @@ -340,8 +344,13 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // onboarding messages. The skeleton avoids flashing wrong content. const shouldShowSkeletonForConciergePanel = isConciergeSidePanel && !hasOnceLoadedReportActions && !isOffline; + // Show skeleton while waiting for the transaction thread report to load after + // an optimistic expense creation. This prevents the partial "amount with nothing else" + // flash between the orchestrator skeleton and the fully rendered single-expense view. + const shouldShowSkeletonForTransactionThread = isWaitingForTransactionThread && !isOffline; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad; + const shouldShowSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad || shouldShowSkeletonForTransactionThread; useEffect(() => { if (!shouldShowSkeleton || !report) { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 33fad0443c25..c5a86b462071 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -230,7 +230,8 @@ function IOURequestStepConfirmation({ [transaction?.participants, iouType, personalDetails, reportAttributesDerived, privateIsArchivedMap, policy, conciergeReportID], ); const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); - const isFromGlobalCreate = !!(transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `??` would not fall through when isFromGlobalCreate is explicitly `false` (QAB targets an existing report), preventing isFromFloatingActionButton from being considered. + const isFromGlobalCreate = !!(transaction?.isFromGlobalCreate || transaction?.isFromFloatingActionButton); useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action, iouType) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); From 1b426f99b904aac53beb322f9646c577cd2abcca Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 4 May 2026 11:54:22 +0200 Subject: [PATCH 07/17] fix: checks after merge --- src/libs/MoneyRequestReportUtils.ts | 4 ++-- src/pages/Search/SearchPage.tsx | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 861ff88ed14c..bac38336ff69 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -4,7 +4,6 @@ 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 {convertToDisplayString} from './CurrencyUtils'; import {hasDeferredWrite} from './deferredLayoutWrite'; import {isPaidGroupPolicy} from './PolicyUtils'; import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isDeletedParentAction, isMoneyRequestAction} from './ReportActionsUtils'; @@ -140,7 +139,8 @@ function shouldWaitForTransactions(report: OnyxEntry, transactions: Tran const isTransactionThreadView = isReportTransactionThread(report); const hasPendingDismissWrite = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); const isStillLoadingData = - transactions?.length === 0 && ((!!reportLoadingState?.isLoadingInitialReportActions && !reportLoadingState.hasOnceLoadedReportActions) || report?.total !== 0 || hasPendingDismissWrite); + transactions?.length === 0 && + ((!!reportLoadingState?.isLoadingInitialReportActions && !reportLoadingState.hasOnceLoadedReportActions) || report?.total !== 0 || hasPendingDismissWrite); return ( (isMoneyRequestReport(report) || isInvoiceReport(report)) && (!isTransactionDataReady || isStillLoadingData) && diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index d55df8dff642..4cfbc9d6c191 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import Animated from 'react-native-reanimated'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchParams} from '@components/Search/types'; @@ -39,21 +39,22 @@ function SearchPage({route}: SearchPageProps) { const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); const [hasFilterBars = false] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: hasFilterBarsSelector}); - const lastNonEmptySearchResults = useRef(undefined); + const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState(undefined); useConfirmReadyToOpenApp(); useSearchPageSetup(currentSearchQueryJSON); + if (currentSearchResults?.data && currentSearchResults !== lastNonEmptySearchResults) { + setLastNonEmptySearchResults(currentSearchResults); + } + useEffect(() => { if (!currentSearchResults?.search?.type) { return; } setLastSearchType(currentSearchResults.search.type); - if (currentSearchResults.data) { - lastNonEmptySearchResults.current = currentSearchResults; - } - }, [lastSearchType, currentSearchQueryJSON, setLastSearchType, currentSearchResults]); + }, [lastSearchType, currentSearchQueryJSON, setLastSearchType, currentSearchResults?.search?.type]); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); @@ -65,7 +66,7 @@ function SearchPage({route}: SearchPageProps) { if (currentSearchResults?.data != null || currentSearchResults?.errors) { searchResults = currentSearchResults; } else if (isSorting) { - searchResults = lastNonEmptySearchResults.current; + searchResults = lastNonEmptySearchResults; } const metadata = searchResults?.search; From 34bfe8c145432d00bfe494510b438a69032c421f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 4 May 2026 13:45:30 +0200 Subject: [PATCH 08/17] address codex comment --- src/CONST/index.ts | 4 ++++ src/pages/inbox/report/ReportActionsView.tsx | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4f47e8be7a69..37f49a4f73f9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7513,6 +7513,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', diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 93ba359c24f0..d08e00e422c9 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSidePanelReportActions from '@hooks/useConciergeSidePanelReportActions'; @@ -271,7 +271,23 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // When an expense is added optimistically, the transaction thread report ID is available // before useOnyx returns the report data (new subscription takes one render cycle). // Detect this transient state so we can keep showing a skeleton instead of a partial view. - const isWaitingForTransactionThread = isSingleExpenseReport && !!transactionThreadReportID && transactionThreadReportID !== CONST.FAKE_REPORT_ID && !transactionThreadReport?.reportID; + // Bounded by a timeout so a missing/failed report load doesn't block the view indefinitely. + const isTransactionThreadPending = isSingleExpenseReport && !!transactionThreadReportID && transactionThreadReportID !== CONST.FAKE_REPORT_ID && !transactionThreadReport?.reportID; + const [transactionThreadTimedOutID, setTransactionThreadTimedOutID] = useState(); + + useEffect(() => { + if (!isTransactionThreadPending || !transactionThreadReportID) { + return; + } + + const timeoutID = setTimeout(() => { + setTransactionThreadTimedOutID(transactionThreadReportID); + }, CONST.SKELETON_LOADING_TIMEOUT_MS); + + return () => clearTimeout(timeoutID); + }, [isTransactionThreadPending, transactionThreadReportID]); + + const isWaitingForTransactionThread = isTransactionThreadPending && transactionThreadTimedOutID !== transactionThreadReportID; const allReportActionIDs = useMemo(() => allReportActions?.map((action) => action.reportActionID) ?? [], [allReportActions]); From d91d6de5cb31ebd91554bebd347fb8c0e69ebdb6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 12:16:57 +0200 Subject: [PATCH 09/17] fix: stale submit-to-destination-visible spans --- src/libs/actions/App.ts | 3 + src/libs/telemetry/middlewares/index.ts | 3 +- .../middlewares/maxDurationFilter.ts | 24 ++++ src/libs/telemetry/submitFollowUpAction.ts | 104 +++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 src/libs/telemetry/middlewares/maxDurationFilter.ts diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index fc50c5fd37af..e59983f7d0d3 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -16,6 +16,7 @@ import {isPublicRoom, isValidReport} from '@libs/ReportUtils'; import {isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; import {cancelAllSpans, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {cancelIfStaleForNavState} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import getPathFromState from '@src/libs/Navigation/helpers/getPathFromState'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -274,6 +275,8 @@ AppState.addEventListener('change', (nextAppState) => { appState = nextAppState; }); +navigationRef.addListener('state', cancelIfStaleForNavState); + /** * Gets the policy params that are passed to the server in the OpenApp and ReconnectApp API commands. This includes a full list of policy IDs the client knows about as well as when they were last modified. */ diff --git a/src/libs/telemetry/middlewares/index.ts b/src/libs/telemetry/middlewares/index.ts index 680557d5e2f3..57703d569719 100644 --- a/src/libs/telemetry/middlewares/index.ts +++ b/src/libs/telemetry/middlewares/index.ts @@ -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; 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 { diff --git a/src/libs/telemetry/middlewares/maxDurationFilter.ts b/src/libs/telemetry/middlewares/maxDurationFilter.ts new file mode 100644 index 000000000000..75a3e9a2198d --- /dev/null +++ b/src/libs/telemetry/middlewares/maxDurationFilter.ts @@ -0,0 +1,24 @@ +import CONST from '@src/CONST'; +import type {TelemetryBeforeSend} from './index'; + +const MAX_SPAN_DURATION_MS = 60_000; + +const maxDurationFilter: TelemetryBeforeSend = (event) => { + const op = event.contexts?.trace?.op; + if (op !== CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE) { + return event; + } + + if (!event.timestamp || !event.start_timestamp) { + return event; + } + + const durationMs = (event.timestamp - event.start_timestamp) * 1000; + if (durationMs > MAX_SPAN_DURATION_MS) { + return null; + } + + return event; +}; + +export default maxDurationFilter; diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index a233f0fa6857..03448b3c11ba 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -12,7 +12,9 @@ import type {SpanAttributeValue} from '@sentry/core'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; +import {navigationRef} from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from './activeSpans'; type SubmitFollowUpAction = ValueOf; @@ -46,12 +48,18 @@ type TrackingState = { skipSubmitExpenseSpan: boolean; }; +// No real expense submit flow should take longer than this from button press +// to destination visible. Anything beyond this is a stuck span (e.g. user +// backgrounded mid-flow, destination screen never mounted, focus never fired). +const SPAN_SAFETY_TIMEOUT_MS = 60_000; + // Module-level mutable state. Safe because JS is single-threaded: each // mutation runs to completion before any queued rAF callback can execute. // startTracking() calls cancelTracking() first, ensuring a clean slate // even if the previous flow's async callbacks haven't fired yet. let trackingState: TrackingState | null = null; let pendingSubmitFollowUpAction: PendingSubmitFollowUpAction = null; +let safetyTimeoutId: ReturnType | null = null; // --------------------------------------------------------------------------- // Follow-up action state @@ -162,6 +170,8 @@ function endSubmitFollowUpActionSpan(followUpAction: SubmitFollowUpAction, repor return; } + clearSafetyTimeout(); + // Uses performance.now() for the dev log duration because the Sentry span's internal // start time is not accessible via the public API. The Sentry span tracks its own // duration independently for production metrics; this timer is only for the dev log. @@ -190,10 +200,19 @@ function endSubmitFollowUpActionSpan(followUpAction: SubmitFollowUpAction, repor clearPendingSubmitFollowUpAction(); } +function clearSafetyTimeout() { + if (safetyTimeoutId === null) { + return; + } + clearTimeout(safetyTimeoutId); + safetyTimeoutId = null; +} + /** * Cancel the submit-to-visible span and clear the pending follow-up action. */ function cancelSubmitFollowUpActionSpan() { + clearSafetyTimeout(); cancelSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE); clearPendingSubmitFollowUpAction(); trackingState = null; @@ -234,6 +253,14 @@ function startTracking(context: SubmitExpenseContext, options?: StartTrackingOpt startTime: performance.now(), skipSubmitExpenseSpan: skip, }; + + safetyTimeoutId = setTimeout(() => { + safetyTimeoutId = null; + if (trackingState) { + Log.warn('[SubmitExpense] Safety timeout: span still open after 60s, cancelling'); + cancelTracking(); + } + }, SPAN_SAFETY_TIMEOUT_MS); } /** @@ -287,5 +314,80 @@ function isTracking(): boolean { return trackingState !== null; } -export {endSubmitFollowUpActionSpan, setPendingSubmitFollowUpAction, getPendingSubmitFollowUpAction, cancelSubmitFollowUpActionSpan, startTracking, setFastPath, addOptimization, isTracking}; +/** + * Check whether the pending span is still valid given the current navigation + * state. Call this on every navigation state change to cancel spans that can + * no longer complete (user navigated away from the expected destination). + */ +function cancelIfStaleForNavState() { + if (!trackingState) { + return; + } + + const pending = pendingSubmitFollowUpAction; + if (!pending || !getSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE)) { + return; + } + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + + const lastRoute = rootState.routes.at(-1); + const hasModalOpen = lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || lastRoute?.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR; + + // While a modal/RHP is open, the submit flow may still be in progress + // (the confirmation screen itself is a modal). Don't cancel prematurely. + if (hasModalOpen) { + return; + } + + const topmostFullScreenRoute = rootState.routes.findLast((route) => { + const name = route.name; + return ( + name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || + name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR || + name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || + name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR + ); + }); + const isOnSearchRoot = topmostFullScreenRoute?.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; + const isOnReport = topmostFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + + switch (pending.followUpAction) { + case CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH: + if (!isOnSearchRoot) { + Log.info('[SubmitExpense] Stale span: expected search but user is elsewhere, cancelling'); + cancelTracking(); + } + break; + case CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT: + if (!isOnReport) { + Log.info('[SubmitExpense] Stale span: expected report but user is elsewhere, cancelling'); + cancelTracking(); + } + break; + case CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY: + if (!isOnSearchRoot && !isOnReport) { + Log.info('[SubmitExpense] Stale span: modal dismissed but user on unexpected screen, cancelling'); + cancelTracking(); + } + break; + default: + break; + } +} + +export { + endSubmitFollowUpActionSpan, + setPendingSubmitFollowUpAction, + getPendingSubmitFollowUpAction, + cancelSubmitFollowUpActionSpan, + startTracking, + setFastPath, + addOptimization, + isTracking, + cancelIfStaleForNavState, +}; export type {SubmitFollowUpAction, PendingSubmitFollowUpAction, FastPathType, Optimization, SubmitExpenseContext, StartTrackingOptions}; From 21b8a991b8cd5df9b1485b40bcd9dd42b98bdc78 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 12:39:03 +0200 Subject: [PATCH 10/17] address ai comments --- src/pages/Search/SearchPage.tsx | 2 +- .../request/step/confirmation/SubmitExpenseOrchestrator.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4cfbc9d6c191..9241ee5eac14 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -44,7 +44,7 @@ function SearchPage({route}: SearchPageProps) { useConfirmReadyToOpenApp(); useSearchPageSetup(currentSearchQueryJSON); - if (currentSearchResults?.data && currentSearchResults !== lastNonEmptySearchResults) { + if (currentSearchResults?.data && !currentSearchKey && currentSearchResults !== lastNonEmptySearchResults) { setLastNonEmptySearchResults(currentSearchResults); } diff --git a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx index 8b02fbdbf7d7..5bfd12c113b5 100644 --- a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx +++ b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useRef, useState} from 'react'; import LocationPermissionModal from '@components/LocationPermissionModal'; import DateUtils from '@libs/DateUtils'; -import {flushDeferredWrite, reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; +import {cancelDeferredWrite, flushDeferredWrite, reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; @@ -315,6 +315,9 @@ function SubmitExpenseOrchestrator({ } Log.warn('[SubmitExpenseOrchestrator] handleReportInRHPDismiss reached without destinationReportID - falling back to default submit'); + if (isDestinationEmpty) { + cancelDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); + } handleDefaultSubmit(listOfParticipants); }; From 19cfbdb936b655f0cd3f5b9390f00cf8c8f4f1da Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 12:48:53 +0200 Subject: [PATCH 11/17] remove listener from App --- src/libs/actions/App.ts | 3 --- src/libs/telemetry/submitFollowUpAction.ts | 8 +++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index e59983f7d0d3..fc50c5fd37af 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -16,7 +16,6 @@ import {isPublicRoom, isValidReport} from '@libs/ReportUtils'; import {isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; import {cancelAllSpans, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; -import {cancelIfStaleForNavState} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import getPathFromState from '@src/libs/Navigation/helpers/getPathFromState'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -275,8 +274,6 @@ AppState.addEventListener('change', (nextAppState) => { appState = nextAppState; }); -navigationRef.addListener('state', cancelIfStaleForNavState); - /** * Gets the policy params that are passed to the server in the OpenApp and ReconnectApp API commands. This includes a full list of policy IDs the client knows about as well as when they were last modified. */ diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index 03448b3c11ba..3fdcb53b3c02 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -12,7 +12,7 @@ import type {SpanAttributeValue} from '@sentry/core'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; -import {navigationRef} from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from './activeSpans'; @@ -60,6 +60,7 @@ const SPAN_SAFETY_TIMEOUT_MS = 60_000; let trackingState: TrackingState | null = null; let pendingSubmitFollowUpAction: PendingSubmitFollowUpAction = null; let safetyTimeoutId: ReturnType | null = null; +let navListenerRegistered = false; // --------------------------------------------------------------------------- // Follow-up action state @@ -223,6 +224,11 @@ function cancelSubmitFollowUpActionSpan() { // --------------------------------------------------------------------------- function startTracking(context: SubmitExpenseContext, options?: StartTrackingOptions) { + if (!navListenerRegistered) { + navListenerRegistered = true; + navigationRef.addListener('state', cancelIfStaleForNavState); + } + cancelTracking(); const skip = options?.skipSubmitExpenseSpan ?? false; From 0ef88ebc53a38264d8318e5cf77693931b8333c6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 13:09:43 +0200 Subject: [PATCH 12/17] address ai comments pt 2 --- src/libs/SearchUIUtils.ts | 1 + src/libs/telemetry/submitFollowUpAction.ts | 17 ++++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index adcf21890df5..55bb5167baf3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -606,6 +606,7 @@ const SKIPPED_SEARCH_FILTERS = new Set([ 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) { diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index 3fdcb53b3c02..5c7dea700b3b 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -12,6 +12,8 @@ import type {SpanAttributeValue} from '@sentry/core'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; +import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -349,17 +351,10 @@ function cancelIfStaleForNavState() { return; } - const topmostFullScreenRoute = rootState.routes.findLast((route) => { - const name = route.name; - return ( - name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || - name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR || - name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || - name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR - ); - }); - const isOnSearchRoot = topmostFullScreenRoute?.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; - const isOnReport = topmostFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + const topmostFullScreenRoute = rootState.routes.findLast((route) => isFullScreenName(route.name)); + const activeTabName = getActiveTabName(topmostFullScreenRoute); + const isOnSearchRoot = activeTabName === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; + const isOnReport = activeTabName === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; switch (pending.followUpAction) { case CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH: From ea6439eaab3f67f54cc3fe15c57b16c49e20379b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 13:12:54 +0200 Subject: [PATCH 13/17] fix IOURequestStepConfirmationPageTest --- src/libs/telemetry/submitFollowUpAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index 5c7dea700b3b..a5a2bf349207 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -226,7 +226,7 @@ function cancelSubmitFollowUpActionSpan() { // --------------------------------------------------------------------------- function startTracking(context: SubmitExpenseContext, options?: StartTrackingOptions) { - if (!navListenerRegistered) { + if (!navListenerRegistered && navigationRef.addListener) { navListenerRegistered = true; navigationRef.addListener('state', cancelIfStaleForNavState); } From cd3071f6a54070d076110efacf7fb4c3741ed218 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 13:26:41 +0200 Subject: [PATCH 14/17] address ai comments pt 3 --- .../iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx index 5bfd12c113b5..1cc5f574f03a 100644 --- a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx +++ b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx @@ -287,7 +287,7 @@ function SubmitExpenseOrchestrator({ const rootState = navigationRef.getRootState(); const report = destinationReportID ? getReportOrDraftReport(destinationReportID) : undefined; - const isDestinationEmpty = !isMoneyRequestReport(report) || report?.transactionCount === 0; + const isDestinationEmpty = !!report && isMoneyRequestReport(report) && report.transactionCount === 0; if (isDestinationEmpty) { reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); } From 8882e4bd66efb6aea3ee3b817a5847246db86715 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 13:46:55 +0200 Subject: [PATCH 15/17] address ai comments pt 4 --- src/hooks/useSearchOverlay.tsx | 5 ++++- src/pages/Search/SearchPage.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx index d11bc5020de9..6d6bac751f5e 100644 --- a/src/hooks/useSearchOverlay.tsx +++ b/src/hooks/useSearchOverlay.tsx @@ -1,6 +1,7 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; import {useSession} from '@components/OnyxListItemProvider'; import SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -115,7 +116,9 @@ function useSearchOverlay({ columns={overlayColumns} contentContainerStyle={shouldUseNarrowLayout ? contentContainerStyle : undefined} /> - ) : null; + ) : ( + + ); return {searchOverlayContent, onSearchContentReady}; } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9241ee5eac14..6e3fe7ff7819 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -33,7 +33,7 @@ function SearchPage({route}: SearchPageProps) { useDocumentTitle(translate('common.spend')); const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); - const {selectedTransactions, lastSearchType, areAllMatchingItemsSelected, currentSearchKey, currentSearchResults, currentSearchQueryJSON} = useSearchStateContext(); + const {selectedTransactions, lastSearchType, areAllMatchingItemsSelected, currentSearchKey, currentSearchResults, currentSearchQueryJSON, shouldUseLiveData} = useSearchStateContext(); const {clearSelectedTransactions, setLastSearchType} = useSearchActionsContext(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); @@ -44,7 +44,7 @@ function SearchPage({route}: SearchPageProps) { useConfirmReadyToOpenApp(); useSearchPageSetup(currentSearchQueryJSON); - if (currentSearchResults?.data && !currentSearchKey && currentSearchResults !== lastNonEmptySearchResults) { + if (currentSearchResults?.data && !shouldUseLiveData && currentSearchResults !== lastNonEmptySearchResults) { setLastNonEmptySearchResults(currentSearchResults); } From aadc53228360a9abf45568af3c4a91625bf1e561 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 14:21:40 +0200 Subject: [PATCH 16/17] address ai comments pt 5 --- src/libs/telemetry/submitFollowUpAction.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index a5a2bf349207..f05e7a0e65d1 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -380,15 +380,5 @@ function cancelIfStaleForNavState() { } } -export { - endSubmitFollowUpActionSpan, - setPendingSubmitFollowUpAction, - getPendingSubmitFollowUpAction, - cancelSubmitFollowUpActionSpan, - startTracking, - setFastPath, - addOptimization, - isTracking, - cancelIfStaleForNavState, -}; +export {endSubmitFollowUpActionSpan, setPendingSubmitFollowUpAction, getPendingSubmitFollowUpAction, cancelSubmitFollowUpActionSpan, startTracking, setFastPath, addOptimization, isTracking}; export type {SubmitFollowUpAction, PendingSubmitFollowUpAction, FastPathType, Optimization, SubmitExpenseContext, StartTrackingOptions}; From 879c2ea9f7289717b6e574fc535598e0ae9e5b35 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 5 May 2026 14:40:18 +0200 Subject: [PATCH 17/17] address ai comments pt 6 --- src/hooks/useSearchOverlay.tsx | 11 +++++------ src/pages/Search/SearchPage.tsx | 3 ++- src/pages/Search/SearchPageNarrow/index.tsx | 5 +++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx index 6d6bac751f5e..e35611464b02 100644 --- a/src/hooks/useSearchOverlay.tsx +++ b/src/hooks/useSearchOverlay.tsx @@ -1,7 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; import {useSession} from '@components/OnyxListItemProvider'; import SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -30,6 +29,8 @@ type UseSearchOverlayParams = { type UseSearchOverlayResult = { searchOverlayContent: React.ReactNode; onSearchContentReady: () => void; + /** Whether the overlay lifecycle is active (armed but not yet ready). */ + isOverlayActive: boolean; }; /** @@ -80,7 +81,7 @@ function useSearchOverlay({ // 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}; + return {searchOverlayContent: null, onSearchContentReady, isOverlayActive: false}; } const isTransaction = isTransactionSearchType(queryJSON?.type); @@ -116,11 +117,9 @@ function useSearchOverlay({ columns={overlayColumns} contentContainerStyle={shouldUseNarrowLayout ? contentContainerStyle : undefined} /> - ) : ( - - ); + ) : null; - return {searchOverlayContent, onSearchContentReady}; + return {searchOverlayContent, onSearchContentReady, isOverlayActive: true}; } export default useSearchOverlay; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 6e3fe7ff7819..fca68e533efa 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -137,7 +137,7 @@ function SearchPage({route}: SearchPageProps) { }, []); const overlayContentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined; - const {searchOverlayContent, onSearchContentReady} = useSearchOverlay({ + const {searchOverlayContent, onSearchContentReady, isOverlayActive} = useSearchOverlay({ searchResults, queryJSON: currentSearchQueryJSON, shouldUseNarrowLayout, @@ -160,6 +160,7 @@ function SearchPage({route}: SearchPageProps) { searchOverlayContent={searchOverlayContent} onSearchContentReady={onSearchContentReady} hasFilterBars={hasFilterBars} + isOverlayActive={isOverlayActive} /> ) : ( void; hasFilterBars: boolean; + /** Whether the overlay lifecycle is active (used to trigger onSearchLayout independently of overlay content). */ + isOverlayActive: boolean; }; const tabBarContent = ; @@ -78,6 +80,7 @@ function SearchPageNarrow({ searchOverlayContent, onSearchContentReady, hasFilterBars, + isOverlayActive, }: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); @@ -359,6 +362,7 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {isOverlayActive && !searchOverlayContent && } {!!searchOverlayContent && ( )} + {isOverlayActive && !searchOverlayContent && } {!!searchOverlayContent && (