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};