diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fb4643629a05..26d862fdd812 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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', diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 34c7fad51de2..51e833232bc7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -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'; @@ -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; } @@ -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 && } + {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, - FILTER_KEYS.KEYWORD, -]); - function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) { return `Search-Filter-${filterKey}`; } @@ -206,7 +202,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)) { @@ -430,4 +426,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 14dee3c1f1a6..e4b59e65c277 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -55,6 +55,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, + isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -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'; @@ -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. @@ -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 ( - - ); + // 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 ; } // This is a performance optimization for the submit-expense->search path only. diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx new file mode 100644 index 000000000000..e35611464b02 --- /dev/null +++ b/src/hooks/useSearchOverlay.tsx @@ -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; + 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; + /** 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()); + + 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 ? ( + + ) : null; + + return {searchOverlayContent, onSearchContentReady, isOverlayActive: true}; +} + +export default useSearchOverlay; diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 377c83451e05..bac38336ff69 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -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 { @@ -136,7 +137,10 @@ function shouldWaitForTransactions(report: OnyxEntry, 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) && diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e7b39fd8ff74..55bb5167baf3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -597,6 +597,18 @@ type GetSectionsParams = { */ const GENERIC_SEARCH_KEYS: ReadonlySet = 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, + 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); } @@ -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) { const matchedSearchKey = Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key; return !!matchedSearchKey && TODO_SEARCH_KEYS.has(matchedSearchKey); @@ -5842,6 +5858,8 @@ export { FILTER_LABEL_MAP, doesSearchItemMatchSort, isPolicyEligibleForSpendOverTime, + isTransactionSearchType, + SKIPPED_SEARCH_FILTERS, }; export type { SavedSearchMenuItem, 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..f05e7a0e65d1 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -12,7 +12,11 @@ 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'; import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from './activeSpans'; type SubmitFollowUpAction = ValueOf; @@ -46,12 +50,19 @@ 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; +let navListenerRegistered = false; // --------------------------------------------------------------------------- // Follow-up action state @@ -162,6 +173,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 +203,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; @@ -204,6 +226,11 @@ function cancelSubmitFollowUpActionSpan() { // --------------------------------------------------------------------------- function startTracking(context: SubmitExpenseContext, options?: StartTrackingOptions) { + if (!navListenerRegistered && navigationRef.addListener) { + navListenerRegistered = true; + navigationRef.addListener('state', cancelIfStaleForNavState); + } + cancelTracking(); const skip = options?.skipSubmitExpenseSpan ?? false; @@ -234,6 +261,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 +322,63 @@ function isTracking(): boolean { return trackingState !== null; } +/** + * 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) => 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: + 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}; export type {SubmitFollowUpAction, PendingSubmitFollowUpAction, FastPathType, Optimization, SubmitExpenseContext, StartTrackingOptions}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index a235f2ddbee9..fca68e533efa 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'; @@ -7,8 +7,10 @@ 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 useSearchOverlay from '@hooks/useSearchOverlay'; import useSearchPageSetup from '@hooks/useSearchPageSetup'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,7 +19,9 @@ import {search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {SearchResults} from '@src/types/onyx'; import SearchPageNarrow from './SearchPageNarrow'; import SearchPageWide from './SearchPageWide'; @@ -29,26 +33,28 @@ 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); + 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 && !shouldUseLiveData && 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 ?? {}); @@ -60,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; @@ -130,6 +136,16 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(true); }, []); + const overlayContentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined; + const {searchOverlayContent, onSearchContentReady, isOverlayActive} = useSearchOverlay({ + searchResults, + queryJSON: currentSearchQueryJSON, + shouldUseNarrowLayout, + isMobileSelectionModeEnabled, + currentSearchKey, + contentContainerStyle: overlayContentContainerStyle, + }); + return ( {shouldUseNarrowLayout ? ( @@ -141,6 +157,10 @@ function SearchPage({route}: SearchPageProps) { footerData={footerData} shouldShowFooter={shouldShowFooter} onSortPressedCallback={onSortPressedCallback} + searchOverlayContent={searchOverlayContent} + onSearchContentReady={onSearchContentReady} + hasFilterBars={hasFilterBars} + isOverlayActive={isOverlayActive} /> ) : ( )} diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index c26e9978033c..2d355ee924b5 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,15 +17,12 @@ 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 SearchStaticList from '@components/Search/SearchStaticList'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSearchLoadingState from '@hooks/useSearchLoadingState'; @@ -36,16 +32,13 @@ 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'; 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 type {SearchResults} from '@src/types/onyx'; import type {SearchResultsInfo} from '@src/types/onyx/SearchResults'; import {SearchActionsBarSwitch, SearchFiltersBarSwitch, SearchPageInputSwitch, SearchTypeMenuSwitch} from './Switches'; @@ -66,16 +59,29 @@ type SearchPageNarrowProps = { }; shouldShowFooter: boolean; onSortPressedCallback: () => void; + /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ + searchOverlayContent: React.ReactNode; + onSearchContentReady: () => void; + hasFilterBars: boolean; + /** Whether the overlay lifecycle is active (used to trigger onSearchLayout independently of overlay content). */ + isOverlayActive: boolean; }; -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({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter, onSortPressedCallback}: SearchPageNarrowProps) { +function SearchPageNarrow({ + queryJSON, + searchResults, + isMobileSelectionModeEnabled, + metadata, + footerData, + shouldShowFooter, + onSortPressedCallback, + searchOverlayContent, + onSearchContentReady, + hasFilterBars, + isOverlayActive, +}: SearchPageNarrowProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -96,7 +102,6 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable 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); @@ -189,7 +194,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 +208,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 +246,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; - return ( )} - {showStaticOverlay && ( - - + {isOverlayActive && !searchOverlayContent && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + {!useStaticRendering && ( + <> + {shouldShowLoadingSkeleton ? ( + 0 && !isOffline, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, + }} + /> + ) : ( + + )} + {isOverlayActive && !searchOverlayContent && } + {!!searchOverlayContent && ( + + {searchOverlayContent} )} )} - {!useStaticRendering && - (shouldShowLoadingSkeleton ? ( - 0 && !isOffline, - hasPendingResponse: searchRequestResponseStatusCode === null, - shouldUseLiveData, - }} - /> - ) : ( - - ))} )} {shouldShowFooter && !searchRouterListVisible && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 92ab04fbbd08..021ec9af989e 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,9 @@ 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; }; function SearchPageWide({ @@ -52,6 +55,8 @@ function SearchPageWide({ onSortPressedCallback, route, shouldShowFooter, + searchOverlayContent, + onSearchContentReady, }: SearchPageWideProps) { const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const styles = useThemeStyles(); @@ -109,33 +114,37 @@ function SearchPageWide({ onSort={onSortPressedCallback} handleSearch={handleSearchAction} /> - {shouldShowLoadingSkeleton ? ( - 0 && !isOffline, - hasPendingResponse: searchRequestResponseStatusCode === null, - shouldUseLiveData, - }} - /> - ) : ( - - )} + + {shouldShowLoadingSkeleton ? ( + 0 && !isOffline, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, + }} + /> + ) : ( + + )} + {!!searchOverlayContent && {searchOverlayContent}} + {shouldShowFooter && ( 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) && } (); + + 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]); @@ -328,8 +348,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 309763321dcd..473d8cc50527 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -230,7 +230,9 @@ 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); const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; @@ -305,8 +307,10 @@ function IOURequestStepConfirmation({ // Only eligible when search pre-insert didn't win, and the flow ends at a report (not Search). // Split flows handle their own dismiss/navigation, so pre-inserting would cause double navigation. + // When Search is the topmost fullscreen and there's no report context (e.g. QAB from Spend tab), + // pre-inserting a report is wrong - the user should stay on Search after submission. const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; - const canUseReportPreInsert = !isSplitRequest && !shouldPreInsertSearch && (!isFromGlobalCreate || isReportTopmostSplitNavigator()); + const canUseReportPreInsert = !isSplitRequest && !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || (!isFromGlobalCreate && !isSearchTopmostFullScreenRoute())); // RHP has its own dismiss handler; pre-inserting under it would break the stack. const isOutsideRHP = !isReportOpenInRHP(navigationRef.getRootState()); diff --git a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx index a7e4fad027f2..1cc5f574f03a 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 {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'; @@ -10,7 +10,7 @@ import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopm import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import {getReportOrDraftReport} from '@libs/ReportUtils'; +import {getReportOrDraftReport, isMoneyRequestReport} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import {setFastPath, setPendingSubmitFollowUpAction, startTracking} from '@libs/telemetry/submitFollowUpAction'; @@ -278,14 +278,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 = !!report && 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); }; @@ -301,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); }; diff --git a/src/selectors/AdvancedSearchFiltersForm.ts b/src/selectors/AdvancedSearchFiltersForm.ts index 6ff050d3e7b6..dc33dca35e0a 100644 --- a/src/selectors/AdvancedSearchFiltersForm.ts +++ b/src/selectors/AdvancedSearchFiltersForm.ts @@ -1,7 +1,14 @@ import type {OnyxEntry} from 'react-native-onyx'; +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'; 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 ?? {}).some(([key, value]) => shouldShowFilter(SKIPPED_SEARCH_FILTERS, key as SearchAdvancedFiltersKey, value, type)); +}; + +export {columnsSelector, hasFilterBarsSelector};