diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e57a1b1f9cf8e..bfc4499bb6eb4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -271,6 +271,12 @@ const CONST = { ANIMATION_PAID_BUTTON_HIDE_DELAY: 300, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, + + // Delay before pre-inserting the Search fullscreen route under the RHP on the confirmation screen. + // Chosen to be long enough for the RHP entrance animation to complete (~250ms) and avoid jank + // from concurrent navigation state mutations, but short enough to finish well before most users + // tap submit. If the user submits before this fires, the fallback (non-pre-insert) path is used. + PRE_INSERT_FULLSCREEN_DELAY: 300, LIMIT_TIMEOUT: 2147483647, ARROW_HIDE_DELAY: 3000, MAX_IMAGE_CANVAS_AREA: 16777216, @@ -6255,6 +6261,7 @@ const CONST = { /** These action types are custom for RootNavigator */ DISMISS_MODAL: 'DISMISS_MODAL', REPLACE_FULLSCREEN_UNDER_RHP: 'REPLACE_FULLSCREEN_UNDER_RHP', + REMOVE_FULLSCREEN_UNDER_RHP: 'REMOVE_FULLSCREEN_UNDER_RHP', OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT', OPEN_DOMAIN_SPLIT: 'OPEN_DOMAIN_SPLIT', PUSH_PARAMS: 'PUSH_PARAMS', @@ -8668,6 +8675,8 @@ const CONST = { MODAL_EVENTS: { CLOSED: 'modalClosed', + DISABLE_RHP_ANIMATION: 'disableRHPAnimation', + RESTORE_RHP_ANIMATION: 'restoreRHPAnimation', }, LIST_BEHAVIOR: { diff --git a/src/components/Search/SearchStaticList.tsx b/src/components/Search/SearchStaticList.tsx index 32ea7fcd9d0ac..418b1a6c6761c 100644 --- a/src/components/Search/SearchStaticList.tsx +++ b/src/components/Search/SearchStaticList.tsx @@ -9,7 +9,8 @@ * • This component intentionally avoids expensive hooks and Onyx reads. * Do NOT add new subscriptions unless absolutely necessary for correctness. */ -import React, {useRef, useState} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useRef, useState} from 'react'; import {FlatList, View} from 'react-native'; import type {ListRenderItemInfo, StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; @@ -24,7 +25,7 @@ import {hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Navigation from '@libs/Navigation/Navigation'; import {isOneTransactionReport} from '@libs/ReportUtils'; import {createAndOpenSearchTransactionThread, getSections, getSortedSections, getValidGroupBy, isCorrectSearchUserName} from '@libs/SearchUIUtils'; -import {endSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -42,6 +43,7 @@ type SearchStaticListProps = { queryJSON: SearchQueryJSON; contentContainerStyle?: StyleProp; onLayout?: () => void; + onDestinationVisible?: (wasListEmpty: boolean, source: 'focus' | 'layout') => void; }; function StaticActionButton({action}: {action: SearchTransactionAction | undefined}) { @@ -64,7 +66,7 @@ function StaticActionButton({action}: {action: SearchTransactionAction | undefin ); } -function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLayout: onLayoutProp}: SearchStaticListProps) { +function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLayout: onLayoutProp, onDestinationVisible}: SearchStaticListProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); @@ -72,7 +74,9 @@ function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLa const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; const email = session?.email; - const [showPendingExpensePlaceholder] = useState(() => hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH)); + const [showPendingExpensePlaceholder, setShowPendingExpensePlaceholder] = useState( + () => hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) || Navigation.getIsFullscreenPreInsertedUnderRHP(), + ); const {type, status, sortBy, sortOrder, groupBy} = queryJSON; const validGroupBy = getValidGroupBy(groupBy); @@ -96,10 +100,27 @@ function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLa }); return getSortedSections(type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy) - .filter((item): item is TransactionListItemType => 'transactionID' in item) + .filter((item): item is TransactionListItemType => 'transactionID' in item && item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .slice(0, STATIC_LIST_MAX_ITEMS); })(); + // Sync the pending-expense placeholder on focus and notify the parent that + // the destination is visible (focus signal for the dual-gate span ending). + useFocusEffect( + useCallback(() => { + const hasPendingAction = getPendingSubmitFollowUpAction()?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH; + if (!showPendingExpensePlaceholder && hasPendingAction) { + setShowPendingExpensePlaceholder(true); + } else if (showPendingExpensePlaceholder && !hasPendingAction && sortedData.length > 0) { + // Only clear the placeholder once real data is available to avoid + // a blank flash when the stale snapshot has been filtered empty. + setShowPendingExpensePlaceholder(false); + } + + onDestinationVisible?.(sortedData.length === 0, 'focus'); + }, [showPendingExpensePlaceholder, sortedData.length, onDestinationVisible]), + ); + const onPressItem = (item: TransactionListItemType) => { const backTo = Navigation.getActiveRoute(); @@ -197,14 +218,7 @@ function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLa } hasEndedSpanRef.current = true; - const pending = getPendingSubmitFollowUpAction(); - if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { - endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: sortedData.length === 0, - }); - } - + onDestinationVisible?.(sortedData.length === 0, 'layout'); onLayoutProp?.(); }; @@ -264,5 +278,6 @@ export default React.memo( prev.searchResults?.data === next.searchResults?.data && prev.queryJSON === next.queryJSON && prev.contentContainerStyle === next.contentContainerStyle && - prev.onLayout === next.onLayout, + prev.onLayout === next.onLayout && + prev.onDestinationVisible === next.onDestinationVisible, ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d752727e434fd..c350b5196b32e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -55,7 +55,7 @@ import { shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; -import {cancelSubmitFollowUpActionSpan, endSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; @@ -93,6 +93,10 @@ type SearchProps = { /** Pre-rendered content shown on the first frame while hooks initialize and heavy work is deferred. */ initialContent?: React.ReactNode; + + /** Callback from the parent (SearchPageNarrow) to end submit-expense navigation spans. + * Consolidates span-ending logic in one place. Accepts `wasListEmpty` for telemetry attributes. */ + onDestinationVisible?: (wasListEmpty: boolean, source: 'focus' | 'layout') => void; }; // Max time (ms) to keep the optimistic item cache/skeleton alive before @@ -220,6 +224,7 @@ function Search({ searchRequestResponseStatusCode, onContentReady, initialContent, + onDestinationVisible, }: SearchProps) { const {type, status, sortBy, sortOrder, hash, similarSearchHash, groupBy, view} = queryJSON; // Deferred write: API.write() is postponed so the skeleton renders instantly. @@ -231,7 +236,7 @@ function Search({ const skipDeferralOnFocusRef = useRef(isSearchDataLoaded(searchResults, queryJSON) && !hasPendingWriteOnMountRef.current); const [shouldDeferHeavySearchWork, setShouldDeferHeavySearchWork] = useState(() => !isSearchDataLoaded(searchResults, queryJSON) || hasPendingWriteOnMountRef.current); - const [showPendingExpensePlaceholder, setShowPendingExpensePlaceholder] = useState(() => hasPendingWriteOnMountRef.current && optimisticWatchKeyRef.current != null); + const [showPendingExpensePlaceholder, setShowPendingExpensePlaceholder] = useState(() => hasPendingWriteOnMountRef.current); // Caches the optimistic list item once it first appears in sortedData. // Used by stableSortedData to re-inject the row if a stale snapshot temporarily removes it. // Cleared once the server-confirmed (non-optimistic) version arrives. @@ -259,7 +264,7 @@ function Search({ // stays visible at its sorted position until server-confirmed data arrives; // clearOptimisticTracking handles that cleanup when pendingAction !== ADD. useEffect(() => { - if (!hasPendingWriteOnMountRef.current || !optimisticWatchKeyRef.current) { + if (!hasPendingWriteOnMountRef.current) { return; } const id = setTimeout(() => setShowPendingExpensePlaceholder(false), OPTIMISTIC_TRACKING_TIMEOUT_MS); @@ -465,8 +470,6 @@ function Search({ return; } - // Re-applying the defer only on the submit-return path keeps the optimization scoped to - // the transition we care about instead of slowing every search refocus. return deferHeavySearchWork(true); }, [deferHeavySearchWork]), ); @@ -656,6 +659,15 @@ function Search({ return; } + // When mounting after the pre-insert fast path, the deferred write hasn't + // been flushed yet. Triggering a search now would race with the CREATE + // API call and return stale results that overwrite the optimistic row. + // Skip this call; the optimistic data from flushDeferredWrite will populate + // the list, and the next user-driven search will refresh from the server. + if (hasPendingWriteOnMountRef.current && hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH)) { + return; + } + if (searchResults?.search?.isLoading) { if (validGroupBy || (shouldCalculateTotals && searchResults?.search?.count === undefined)) { shouldRetrySearchWithTotalsOrGroupedRef.current = true; @@ -1242,9 +1254,26 @@ function Search({ // Server confirmed (pendingAction !== ADD) -> clear all tracking. // Disappeared after caching (rollback) -> schedule cleanup after grace period. useEffect(() => { - if (!optimisticWatchKeyRef.current) { + if (!hasPendingWriteOnMountRef.current || optimisticTrackingCleanedUpRef.current) { return; } + + // The watch key may not be available at mount when the deferred write channel + // was only reserved (fast path: rAF hasn't fired yet). Try to resolve it lazily + // on each sortedData change. If data arrives before we ever get a key (e.g. the + // channel was flushed between renders), clear tracking since the list is populated. + if (!optimisticWatchKeyRef.current) { + const latestKey = getOptimisticWatchKey(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + if (latestKey) { + optimisticWatchKeyRef.current = latestKey; + } else if (sortedData.length > 0) { + clearOptimisticTracking(); + return; + } else { + return; + } + } + const optimisticItem = sortedData.find( (item): item is TransactionListItemType => 'transactionID' in item && `${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}` === optimisticWatchKeyRef.current, ); @@ -1375,18 +1404,12 @@ function Search({ const onLayout = useCallback(() => { hasHadFirstLayout.current = true; + onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); - const pending = getPendingSubmitFollowUpAction(); - if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { - endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: isSearchResultsEmptyRef.current, - }); - } handleSelectionListScroll(stableSortedData, searchListRef.current); flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); onContentReady?.(); - }, [handleSelectionListScroll, stableSortedData, onContentReady]); + }, [handleSelectionListScroll, stableSortedData, onContentReady, onDestinationVisible]); // Must be a ref, not state: cancelNavigationSpans is called during render // (inside conditional returns), so using setState would trigger infinite re-renders. @@ -1425,19 +1448,13 @@ function Search({ if (!hasHadFirstLayout.current) { return; } - const pending = getPendingSubmitFollowUpAction(); - if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { - endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: !shouldShowLoadingState, - [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: isSearchResultsEmptyRef.current, - }); - } + onDestinationVisible?.(isSearchResultsEmptyRef.current, 'focus'); endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: !shouldShowLoadingState, }); // On re-focus (e.g. DISMISS_MODAL_ONLY) onLayout won't re-fire — flush here. flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); - }, [shouldShowLoadingState]), + }, [shouldShowLoadingState, onDestinationVisible]), ); // Reset before conditional returns. Only cancelNavigationSpans (error/empty paths) @@ -1449,9 +1466,6 @@ function Search({ // The SearchPage skeleton (useSearchLoadingState) doesn't cover this case because // Search must mount for its onLayout to flush the deferred CreateMoneyRequest API write, which would block the JS thread causing a slowdown on post expense creation navigation if (shouldShowRowSkeleton) { - // When initialContent is provided (submit-expense flow), render it instead of the skeleton. - // This avoids a jarring "data, skeleton, data" flash. The user sees the same - // static list continuously until the FlashList is ready to take over. if (initialContent) { return ( { + const disableSub = DeviceEventEmitter.addListener(CONST.MODAL_EVENTS.DISABLE_RHP_ANIMATION, () => { + navigation.setOptions({animation: Animations.NONE}); + }); + const restoreSub = DeviceEventEmitter.addListener(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION, () => { + navigation.setOptions({animation: Animations.SLIDE_FROM_RIGHT}); + }); + return () => { + disableSub.remove(); + restoreSub.remove(); + }; + }, [navigation]); + // Animation should be disabled when we open the wide rhp from the narrow one. // When the wide rhp page is opened as first one, it will be animated with the entire RightModalNavigator. const animationEnabledOnSearchReport = superWideRHPRouteKeys.length > 0 || isSmallScreenWidth; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 535f99f8b5ca2..ede57f42a97aa 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -16,6 +16,7 @@ import type { OpenDomainSplitActionType, OpenWorkspaceSplitActionType, PushActionType, + RemoveFullscreenUnderRHPActionType, ReplaceActionType, ReplaceFullscreenUnderRHPActionType, ToggleSidePanelWithHistoryActionType, @@ -268,6 +269,42 @@ function handleReplaceFullscreenUnderRHP( }; } +/** + * Reverses handleReplaceFullscreenUnderRHP by removing the fullscreen route that + * was pre-inserted underneath the currently open modal. + * + * State transition: [Home, Search, RHP] -> [Home, RHP] + * + * Used when the user backs out of the expense confirmation screen without submitting, + * so the pre-inserted destination route is cleaned up. + */ +function handleRemoveFullscreenUnderRHP( + state: StackNavigationState, + action: RemoveFullscreenUnderRHPActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, +) { + const rhpRoute = state.routes.at(-1); + if (rhpRoute?.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + return null; + } + + const routesWithoutRHP = state.routes.slice(0, -1); + if (routesWithoutRHP.length < 2) { + return null; + } + + const preInsertedRoute = routesWithoutRHP.at(-1); + if (!preInsertedRoute || !isFullScreenName(preInsertedRoute.name) || preInsertedRoute.name !== action.payload.expectedRouteName) { + return null; + } + + const routesWithoutPreInserted = routesWithoutRHP.slice(0, -1); + const newRoutes = [...routesWithoutPreInserted, rhpRoute]; + const rehydratedState = stackRouter.getRehydratedState({...state, routes: newRoutes, index: newRoutes.length - 1}, configOptions); + return rehydratedState; +} + /** * Handles the DISMISS_MODAL action. * If the last route is a modal route, it has to be popped from the navigation stack. @@ -328,6 +365,7 @@ export { handleOpenDomainSplitAction, handlePushFullscreenAction, handleReplaceFullscreenUnderRHP, + handleRemoveFullscreenUnderRHP, handleReplaceReportsSplitNavigatorAction, screensWithEnteringAnimation, handleToggleSidePanelWithHistoryAction, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts index fc70a14648019..d11e207660220 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -14,6 +14,7 @@ import { handleOpenDomainSplitAction, handleOpenWorkspaceSplitAction, handlePushFullscreenAction, + handleRemoveFullscreenUnderRHP, handleReplaceFullscreenUnderRHP, handleReplaceReportsSplitNavigatorAction, handleToggleSidePanelWithHistoryAction, @@ -25,6 +26,7 @@ import type { OpenWorkspaceSplitActionType, PreloadActionType, PushActionType, + RemoveFullscreenUnderRHPActionType, ReplaceActionType, ReplaceFullscreenUnderRHPActionType, RootStackNavigatorAction, @@ -56,6 +58,10 @@ function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): ac return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; } +function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; +} + function isToggleSidePanelWithHistoryAction(action: RootStackNavigatorAction): action is ToggleSidePanelWithHistoryActionType { return action.type === CONST.NAVIGATION.ACTION_TYPE.TOGGLE_SIDE_PANEL_WITH_HISTORY; } @@ -155,6 +161,10 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return handleReplaceFullscreenUnderRHP(state, action, configOptions, stackRouter); } + if (isRemoveFullscreenUnderRHPAction(action)) { + return handleRemoveFullscreenUnderRHP(state, action, configOptions, stackRouter); + } + if (isReplaceAction(action) && action.payload.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { return handleReplaceReportsSplitNavigatorAction(state, action, configOptions, stackRouter); } diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index 687a4032367da..85d777121f320 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -17,6 +17,10 @@ type RootStackNavigatorActionType = type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; payload: {route: Route}; } + | { + type: typeof CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; + payload: {expectedRouteName: string}; + } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.OPEN_WORKSPACE_SPLIT; payload: { @@ -69,6 +73,11 @@ type ReplaceFullscreenUnderRHPActionType = RootStackNavigatorActionType & { payload: {route: Route}; }; +type RemoveFullscreenUnderRHPActionType = RootStackNavigatorActionType & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; + payload: {expectedRouteName: string}; +}; + type RootStackNavigatorRouterOptions = StackRouterOptions; type RootStackNavigatorAction = CommonActions.Action | StackActionType | RootStackNavigatorActionType; @@ -81,6 +90,7 @@ export type { DismissModalActionType, PreloadActionType, ReplaceFullscreenUnderRHPActionType, + RemoveFullscreenUnderRHPActionType, RootStackNavigatorAction, RootStackNavigatorRouterOptions, ToggleSidePanelWithHistoryActionType, diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts index 964b1ef025295..a51336d7a00a8 100644 --- a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts @@ -1,5 +1,5 @@ import type {CommonActions, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; -import type {ReplaceFullscreenUnderRHPActionType, RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; +import type {RemoveFullscreenUnderRHPActionType, ReplaceFullscreenUnderRHPActionType, RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import CONST from '@src/CONST'; import {enhanceStateWithHistory} from './utils'; @@ -8,6 +8,10 @@ function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): ac return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; } +function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; +} + /** * Higher-order function that extends a React Navigation stack router with history * management for the root stack navigator. @@ -17,10 +21,10 @@ function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): ac * 1. **Side panel** – preserves the CUSTOM_HISTORY_ENTRY_SIDE_PANEL entry through * rehydration so the side panel open/close state survives navigation state rebuilds. * - * 2. **REPLACE_FULLSCREEN_UNDER_RHP** – freezes the history array for this action so - * that useLinking sees historyDelta=0 and does NOT push/pop any browser history - * entries for this intermediate state change. The correct browser history update - * happens later when DISMISS_MODAL pops the RHP in the next animation frame. + * 2. **REPLACE/REMOVE_FULLSCREEN_UNDER_RHP** - freezes the history array for these + * actions so that useLinking sees historyDelta=0 and does NOT push/pop any browser + * history entries for these intermediate state changes. The correct browser history + * update happens later when DISMISS_MODAL pops the RHP in the next animation frame. */ function addRootHistoryRouterExtension( originalRouter: PlatformStackRouterFactory, @@ -52,11 +56,10 @@ function addRootHistoryRouterExtension isFullScreenName(r.name))?.name; + + const stateBefore = navigationRef.current.getRootState(); + const routeCountBefore = stateBefore.routes.length; + const lastKeyBefore = stateBefore.routes.at(-1)?.key; + + navigationRef.current.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, + payload: {route}, + }); + + const stateAfter = navigationRef.current.getRootState(); + if (stateAfter.routes.length === routeCountBefore && stateAfter.routes.at(-1)?.key === lastKeyBefore) { + Log.hmmm(`[Navigation] preInsertFullscreenUnderRHP dispatch was ignored`, {route}); + return; + } + + isFullscreenPreInsertedUnderRHP = true; + preInsertedFullscreenRouteName = targetRouteName; + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.DISABLE_RHP_ANIMATION); +} + +function getIsFullscreenPreInsertedUnderRHP() { + return isFullscreenPreInsertedUnderRHP; +} + +function clearFullscreenPreInsertedFlag() { + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; +} + +/** + * Removes a pre-inserted fullscreen route when the user backs out without submitting. + * If the RHP is still on top, the pre-inserted route is popped from under it. + * If the RHP is already gone (back-dismissed), the pre-inserted route is the topmost + * fullscreen and is popped directly. + */ +function removePreInsertedFullscreenIfNeeded() { + if (!isFullscreenPreInsertedUnderRHP) { + return; + } + + const routeNameToRemove = preInsertedFullscreenRouteName; + + isFullscreenPreInsertedUnderRHP = false; + preInsertedFullscreenRouteName = undefined; + + DeviceEventEmitter.emit(CONST.MODAL_EVENTS.RESTORE_RHP_ANIMATION); + + const rootState = navigationRef.getRootState(); + if (!rootState) { + return; + } + + const topRoute = rootState.routes.at(-1); + const isRHPStillOnTop = topRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + + if (isRHPStillOnTop && routeNameToRemove) { + navigationRef.current?.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP, + payload: {expectedRouteName: routeNameToRemove}, + }); + return; + } + + // RHP already dismissed - the pre-inserted fullscreen is now the topmost route; pop it. + // Deferred to the next frame to avoid dispatching during a React commit. + // Capture the route key now so the rAF callback can match on identity, not just name. + const targetRouteKey = rootState.routes.at(-1)?.key; + requestAnimationFrame(() => { + const currentState = navigationRef.getRootState(); + const topmostRoute = currentState?.routes.at(-1); + if (!topmostRoute || topmostRoute.key !== targetRouteKey || topmostRoute.name !== routeNameToRemove) { + return; + } + if (!navigationRef.current?.canGoBack()) { + return; + } + navigationRef.current.goBack(); + }); +} + function getTopmostSearchReportRouteParams(state = navigationRef.getRootState()): RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined { if (!state) { return undefined; @@ -1021,6 +1138,10 @@ export default { dismissToPreviousRHP, dismissToSuperWideRHP, revealRouteBeforeDismissingModal, + preInsertFullscreenUnderRHP, + getIsFullscreenPreInsertedUnderRHP, + clearFullscreenPreInsertedFlag, + removePreInsertedFullscreenIfNeeded, getTopmostSearchReportID, getTopmostSuperWideRHPReportParams, getTopmostSuperWideRHPReportID, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 039e5eb41053a..22fff417728ce 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -977,8 +977,19 @@ function handleNavigateAfterExpenseCreate({ ); const queryString = buildCannedSearchQuery({type}); const navigateToSearch = () => { - if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); + // On the fast path, onConfirm already cleared the flag and dismissed the modal, + // so this branch is only reached on the slow path (user submitted before the + // 300ms pre-insert timer fired). + if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + Navigation.clearFullscreenPreInsertedFlag(); + Navigation.dismissModal(); + } else if (getIsNarrowLayout()) { + const isRHPStillOnTop = navigationRef.getRootState()?.routes?.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + if (!alreadyOnSearchRoot || !isSameSearchType || isRHPStillOnTop) { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); + } else { + Log.info('[IOU] navigateToSearch: already on matching Search root with RHP dismissed — no-op'); + } } else { Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); } diff --git a/src/libs/deferredLayoutWrite.ts b/src/libs/deferredLayoutWrite.ts index ec75f126eaefd..ceccf9918c13c 100644 --- a/src/libs/deferredLayoutWrite.ts +++ b/src/libs/deferredLayoutWrite.ts @@ -33,10 +33,27 @@ type DeferredChannel = { * when the optimistic updates have been applied. */ optimisticWatchKey?: OnyxKey; + + /** True when the channel was created by reserveDeferredWriteChannel. */ + isReserved?: boolean; + + /** + * Set when flushDeferredWrite is called while the channel is still reserved. + * Signals that the target component already laid out and tried to flush, + * so registerDeferredWrite should execute the real callback immediately + * instead of creating a new deferred channel. + */ + flushRequested?: boolean; }; const channels = new Map(); +// Watch keys that outlive their channel. When a reserved channel is flushed +// immediately (flushRequested path), the channel is deleted but the watch key +// must remain accessible so Search's lazy getOptimisticWatchKey() resolution +// can still find it. +const flushedWatchKeys = new Map(); + function clearChannelTimeout(channel: DeferredChannel) { clearTimeout(channel.safetyTimeoutId); } @@ -56,8 +73,22 @@ function registerDeferredWrite(key: string, callback: () => void, options: Defer const existing = channels.get(key); if (existing) { - Log.warn(`[DeferredLayoutWrite] Overwriting unflushed deferred write for key "${key}" - flushing the pending one first`); - flushDeferredWrite(key); + if (existing.isReserved) { + clearChannelTimeout(existing); + const shouldFlushImmediately = existing.flushRequested; + channels.delete(key); + + if (shouldFlushImmediately) { + if (optimisticWatchKey) { + flushedWatchKeys.set(key, optimisticWatchKey); + } + callback(); + return; + } + } else { + Log.warn(`[DeferredLayoutWrite] Overwriting unflushed deferred write for key "${key}" - flushing the pending one first`); + flushDeferredWrite(key); + } } const safetyTimeoutId = setTimeout(() => { @@ -71,6 +102,11 @@ function registerDeferredWrite(key: string, callback: () => void, options: Defer /** * Execute and clear the pending deferred write for the given key. * Called by the target component when actual content (not skeleton) lays out. + * + * If the channel is still reserved (real callback not yet registered), the + * flush is deferred: the channel is marked `flushRequested` so that + * registerDeferredWrite will execute the real callback immediately when it + * arrives, instead of creating a new channel that nobody would flush. */ function flushDeferredWrite(key: string) { const channel = channels.get(key); @@ -78,6 +114,11 @@ function flushDeferredWrite(key: string) { return; } + if (channel.isReserved) { + channel.flushRequested = true; + return; + } + clearChannelTimeout(channel); channels.delete(key); channel.write(); @@ -96,6 +137,25 @@ function cancelDeferredWrite(key: string) { channels.delete(key); } +/** + * Pre-create a channel so that hasDeferredWrite(key) returns true immediately. + * The real callback will be registered later via registerDeferredWrite, which + * silently replaces the reservation. A safety timeout is still set in case + * the real registration never arrives. + */ +function reserveDeferredWriteChannel(key: string) { + if (channels.has(key)) { + return; + } + + const safetyTimeoutId = setTimeout(() => { + Log.warn(`[DeferredLayoutWrite] Safety timeout fired for reserved channel "${key}" - the real write was never registered`); + channels.delete(key); + }, DEFAULT_SAFETY_TIMEOUT_MS); + + channels.set(key, {write: () => {}, safetyTimeoutId, isReserved: true}); +} + function hasDeferredWrite(key: string): boolean { return channels.has(key); } @@ -106,7 +166,7 @@ function hasDeferredWrite(key: string): boolean { * or the channel was registered without a watch key. */ function getOptimisticWatchKey(key: string): OnyxKey | undefined { - return channels.get(key)?.optimisticWatchKey; + return channels.get(key)?.optimisticWatchKey ?? flushedWatchKeys.get(key); } // Flush every pending deferred write when the app moves to background so @@ -122,4 +182,4 @@ AppState.addEventListener('change', (nextState) => { } }); -export {registerDeferredWrite, flushDeferredWrite, cancelDeferredWrite, hasDeferredWrite, getOptimisticWatchKey}; +export {registerDeferredWrite, reserveDeferredWriteChannel, flushDeferredWrite, cancelDeferredWrite, hasDeferredWrite, getOptimisticWatchKey}; diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index 2f324636e4ac4..0cbc9ea96d020 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -29,7 +29,13 @@ function isSameFlowUpdate(pending: NonNullable, fol return true; } // Refinement: we first set DISMISS_MODAL_ONLY, then dismissModalWithReport's onBeforeNavigate refines it to DISMISS_MODAL_AND_OPEN_REPORT when the report will open. Same flow — update in place instead of cancelling. - return pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT; + if (pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { + return true; + } + // The fast path (pre-insert) sets NAVIGATE_TO_SEARCH before createTransaction runs. + // handleNavigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it + // sees the Search page as already on top. Treat this as same-flow - keep the original. + return pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY; } /** @@ -43,11 +49,21 @@ function setPendingSubmitFollowUpAction(followUpAction: SubmitFollowUpAction, re const pending = pendingSubmitFollowUpAction; if (pending !== null && span && isSameFlowUpdate(pending, followUpAction, reportID)) { - // Same flow: update in place instead of cancelling (e.g. dismissModalAndOpenReportInInboxTab sets pending, then onBeforeNavigate refines it). - pendingSubmitFollowUpAction = {followUpAction, reportID}; - span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_SUBMIT_FOLLOW_UP_ACTION, followUpAction); - if (reportID !== undefined) { - span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_REPORT_ID, reportID); + // Same flow: only update when the new action is a genuine refinement (e.g. + // DISMISS_MODAL_ONLY -> DISMISS_MODAL_AND_OPEN_REPORT). When the fast path set + // NAVIGATE_TO_SEARCH and handleNavigateAfterExpenseCreate later calls with + // DISMISS_MODAL_ONLY (because Search is already on top), preserve the original + // action so telemetry correctly reflects the pre-insert path. + const isRefinement = + pending.followUpAction !== followUpAction && + !(pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY); + + if (isRefinement) { + pendingSubmitFollowUpAction = {followUpAction, reportID}; + span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_SUBMIT_FOLLOW_UP_ACTION, followUpAction); + if (reportID !== undefined) { + span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_REPORT_ID, reportID); + } } return; } @@ -56,15 +72,20 @@ function setPendingSubmitFollowUpAction(followUpAction: SubmitFollowUpAction, re cancelSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE); pendingSubmitFollowUpAction = null; } - pendingSubmitFollowUpAction = {followUpAction, reportID}; - // Set the attribute on the span immediately so it is present when the transaction is serialized. - // When navigating away (e.g. to Search), the confirmation transaction can end and the SDK may cancel our span before the destination screen mounts to call endSubmitFollowUpActionSpan. + + // Only set pending when the span is still active. On the fast path the span + // may have already been ended by SearchStaticList before createTransaction's + // rAF fires. Setting pending without a span leaves stale state that would + // cancel the next flow's span in the conflict check above. const spanAfter = getSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE); - if (spanAfter) { - spanAfter.setAttribute(CONST.TELEMETRY.ATTRIBUTE_SUBMIT_FOLLOW_UP_ACTION, followUpAction); - if (reportID !== undefined) { - spanAfter.setAttribute(CONST.TELEMETRY.ATTRIBUTE_REPORT_ID, reportID); - } + if (!spanAfter) { + return; + } + + pendingSubmitFollowUpAction = {followUpAction, reportID}; + spanAfter.setAttribute(CONST.TELEMETRY.ATTRIBUTE_SUBMIT_FOLLOW_UP_ACTION, followUpAction); + if (reportID !== undefined) { + spanAfter.setAttribute(CONST.TELEMETRY.ATTRIBUTE_REPORT_ID, reportID); } } diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 28e8d62f9a5c9..2e51a1eb6d603 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -1,4 +1,4 @@ -import {useRoute} from '@react-navigation/native'; +import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useRef, useState, useTransition} from 'react'; import {View} from 'react-native'; import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -33,7 +33,7 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import Navigation from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; -import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import {endSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import variables from '@styles/variables'; import {searchInServer} from '@userActions/Report'; import {search} from '@userActions/Search'; @@ -152,7 +152,18 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable return () => removeRouteKey(route.key); }, [addRouteKey, removeRouteKey, route.key, searchRouterListVisible]); - const [useStaticRendering] = useState(() => getPendingSubmitFollowUpAction()?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); + const navigation = useNavigation(); + // When pre-inserted behind the RHP (not focused), always start in static rendering + // mode so we stay at the lightweight static list until focus arrives. This avoids + // mounting the heavy Search component while hidden and ensures the deferred write + // mechanism works correctly: createTransaction registers the write in the next rAF, + // and the full Search component flushes it when it mounts after focus-driven phase transition. + const [useStaticRendering] = useState(() => { + if (!navigation.isFocused()) { + return true; + } + return getPendingSubmitFollowUpAction()?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH; + }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); @@ -168,14 +179,51 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable setIsHeaderInteractive(true); }); }, [startTransition]); - useEffect(() => { - if (!isHeaderInteractive || isInteractive) { + + const hadFocusRef = useRef(false); + const hadLayoutRef = useRef(false); + + // Single callback for ending submit-expense navigation spans. Passed down + // to SearchStaticList and Search so the logic lives in one place. + // Requires both focus and layout signals before ending — prevents 0ms spans + // on subsequent flows where useFocusEffect fires before the content re-renders. + const endSubmitNavigationSpans = useCallback((wasListEmpty: boolean, source: 'focus' | 'layout') => { + if (source === 'focus') { + hadFocusRef.current = true; + } else { + hadLayoutRef.current = true; + } + + if (!hadFocusRef.current || !hadLayoutRef.current) { return; } - startTransition(() => { - setIsInteractive(true); - }); - }, [isHeaderInteractive, isInteractive, startTransition]); + + hadFocusRef.current = false; + hadLayoutRef.current = false; + + const pending = getPendingSubmitFollowUpAction(); + if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { + endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { + [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, + [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: wasListEmpty, + }); + } + }, []); + + // Wait for focus before transitioning to the full interactive Search component. + // When pre-inserted behind the RHP, this keeps the page at the lightweight static + // list phase until it is actually visible, avoiding wasted work and premature span endings. + // useFocusEffect avoids the extra re-renders that useIsFocused causes on every focus change. + useFocusEffect( + useCallback(() => { + if (!isHeaderInteractive || isInteractive) { + return; + } + startTransition(() => { + setIsInteractive(true); + }); + }, [isHeaderInteractive, isInteractive, startTransition]), + ); if (!queryJSON) { return ( @@ -208,6 +256,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable queryJSON={queryJSON} contentContainerStyle={contentContainerStyle} onLayout={onSearchLayout} + onDestinationVisible={endSubmitNavigationSpans} /> ); @@ -227,6 +276,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable onSearchListScroll={scrollHandler} searchRequestResponseStatusCode={searchRequestResponseStatusCode} initialContent={staticListContent} + onDestinationVisible={endSubmitNavigationSpans} /> ); }; @@ -260,6 +310,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable handleSearch={handleSearchAction} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} searchRequestResponseStatusCode={searchRequestResponseStatusCode} + onDestinationVisible={endSubmitNavigationSpans} /> ); }; diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 0b0287030b540..3e7a0980d2b21 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -20,7 +20,9 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; +import {endSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {SearchResults} from '@src/types/onyx'; @@ -59,6 +61,19 @@ function SearchPageWide({ const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); + // Wide layout doesn't need the focus+layout dual-gate (pre-insert is narrow-only), + // but the callback signature must match onDestinationVisible's type. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const endSubmitNavigationSpans = useCallback((wasListEmpty: boolean, _source: 'focus' | 'layout') => { + const pending = getPendingSubmitFollowUpAction(); + if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { + endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { + [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, + [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: wasListEmpty, + }); + } + }, []); + const scrollHandler = useCallback( (e: NativeSyntheticEvent) => { if (!e.nativeEvent.contentOffset.y) { @@ -132,6 +147,7 @@ function SearchPageWide({ onSearchListScroll={scrollHandler} onSortPressedCallback={onSortPressedCallback} searchRequestResponseStatusCode={searchRequestResponseStatusCode} + onDestinationVisible={endSubmitNavigationSpans} /> )} {shouldShowFooter && ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index c398a9303f972..f4168fa21315b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -37,9 +37,11 @@ import {completeTestDriveTask} from '@libs/actions/Task'; import {isMobileSafari} from '@libs/Browser'; import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import {reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import { @@ -50,6 +52,8 @@ import { shouldUseTransactionDraft, } from '@libs/IOUUtils'; import Log from '@libs/Log'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -64,9 +68,11 @@ import { isReportOutstanding, isSelectedManagerMcTest, } from '@libs/ReportUtils'; +import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; +import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import { getDefaultTaxCode, @@ -359,6 +365,7 @@ function IOURequestStepConfirmation({ const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); const shouldGenerateTransactionThreadReport = !isBetaEnabled(CONST.BETAS.NO_OPTIMISTIC_TRANSACTION_THREADS); const formHasBeenSubmitted = useRef(false); + const isFromGlobalCreate = !!(transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -371,6 +378,53 @@ function IOURequestStepConfirmation({ const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; + // Pre-insert is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate + // (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY, + // SPLIT-from-global-create, per-diem self-DM track) navigate to a specific report instead, + // so pre-inserting Search would leave a stale route in the stack. + const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && !(isPerDiemRequest && iouType === CONST.IOU.TYPE.TRACK); + + const hasPreInsertFired = useRef(false); + const isTransactionReady = !!transaction; + + useEffect(() => { + if ( + hasPreInsertFired.current || + !isTransactionReady || + !getIsNarrowLayout() || + !isFromGlobalCreate || + !canPreInsertSearch || + isReportTopmostSplitNavigator() || + isSearchTopmostFullScreenRoute() + ) { + return; + } + + hasPreInsertFired.current = true; + + const type = iouType === CONST.IOU.TYPE.INVOICE ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + const queryString = buildCannedSearchQuery({type}); + const searchRoute = ROUTES.SEARCH_ROOT.getRoute({query: queryString}); + + const timer = setTimeout(() => { + Navigation.preInsertFullscreenUnderRHP(searchRoute); + }, CONST.PRE_INSERT_FULLSCREEN_DELAY); + + return () => { + clearTimeout(timer); + + if (!Navigation.getIsFullscreenPreInsertedUnderRHP() || formHasBeenSubmitted.current) { + return; + } + + Navigation.removePreInsertedFullscreenIfNeeded(); + }; + // isFromGlobalCreate, iouType, and canPreInsertSearch are stable for the lifetime of + // this screen instance. isTransactionReady may flip from false to true once, which + // re-triggers the effect so the pre-insert fires even when the transaction loads late. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTransactionReady]); + const navigateBack = useCallback(() => { if (backTo) { Navigation.goBack(backTo); @@ -914,45 +968,6 @@ function IOURequestStepConfirmation({ formHasBeenSubmitted.current = true; - const hasReceiptFiles = Object.values(receiptFiles).some((receipt) => !!receipt); - const isFromGlobalCreate = transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton ?? false; - - const scenario = getSubmitExpenseScenario({ - iouType, - isDistanceRequest, - isMovingTransactionFromTrackExpense, - isUnreported, - isCategorizingTrackExpense, - isSharingTrackExpense, - isPerDiemRequest, - isFromGlobalCreate, - hasReceiptFiles, - }); - - const submitSpanAttributes = { - [CONST.TELEMETRY.ATTRIBUTE_SCENARIO]: scenario, - [CONST.TELEMETRY.ATTRIBUTE_HAS_RECEIPT]: hasReceiptFiles, - [CONST.TELEMETRY.ATTRIBUTE_IS_FROM_GLOBAL_CREATE]: isFromGlobalCreate, - [CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType, - [CONST.TELEMETRY.ATTRIBUTE_IOU_REQUEST_TYPE]: requestType ?? 'unknown', - }; - - startSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, { - name: 'submit-expense', - op: CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, - attributes: submitSpanAttributes, - }); - - startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { - name: 'submit-to-destination-visible', - op: CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, - attributes: submitSpanAttributes, - }); - - // IMPORTANT: Every branch below must call markSubmitExpenseEnd() after dispatching the expense action. - // The submit follow-up action span above is ended by the target screen (ReportScreen, Search, etc.) or by runAfterInteractions for dismiss_modal_only. - // This ensures the telemetry span started above is always closed, including inside async getCurrentPosition callbacks. - // If missed, the impact is benign (an orphaned Sentry span), but it pollutes telemetry data. if (iouType !== CONST.IOU.TYPE.TRACK && isDistanceRequest && !isMovingTransactionFromTrackExpense && !isUnreported) { createDistanceRequest(iouType === CONST.IOU.TYPE.SPLIT ? splitParticipants : selectedParticipants, trimmedComment); markSubmitExpenseEnd(); @@ -1192,7 +1207,6 @@ function IOURequestStepConfirmation({ submitPerDiemExpense, policyRecentlyUsedCurrencies, reportID, - requestType, betas, participantsPolicyTags, personalDetails, @@ -1278,6 +1292,40 @@ function IOURequestStepConfirmation({ // To prevent the component from rendering with the wrong currency, we show a loading indicator until the correct currency is set. const isLoading = !!transaction?.originalCurrency; + const startSubmitSpans = () => { + const hasReceiptFiles = Object.values(receiptFiles).some((receipt) => !!receipt); + // Re-derive from transaction inside the callback so telemetry captures the value + // at submission time, not at render time (transaction is mutable Onyx state). + const isFromGlobalCreateForTelemetry = !!(transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton); + const scenario = getSubmitExpenseScenario({ + iouType, + isDistanceRequest, + isMovingTransactionFromTrackExpense, + isUnreported, + isCategorizingTrackExpense, + isSharingTrackExpense, + isPerDiemRequest, + isFromGlobalCreate: isFromGlobalCreateForTelemetry, + hasReceiptFiles, + }); + const submitSpanAttributes = { + [CONST.TELEMETRY.ATTRIBUTE_SCENARIO]: scenario, + [CONST.TELEMETRY.ATTRIBUTE_HAS_RECEIPT]: hasReceiptFiles, + [CONST.TELEMETRY.ATTRIBUTE_IS_FROM_GLOBAL_CREATE]: isFromGlobalCreateForTelemetry, + [CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType, + [CONST.TELEMETRY.ATTRIBUTE_IOU_REQUEST_TYPE]: requestType ?? 'unknown', + }; + + startSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, { + name: 'submit-expense', + op: CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, + })?.setAttributes(submitSpanAttributes); + startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { + name: 'submit-to-destination-visible', + op: CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, + })?.setAttributes(submitSpanAttributes); + }; + const onConfirm = (listOfParticipants: Participant[]) => { setIsConfirming(true); setSelectedParticipantList(listOfParticipants); @@ -1294,13 +1342,31 @@ function IOURequestStepConfirmation({ } } - requestAnimationFrame(() => { - createTransaction(listOfParticipants); - // Keep the pre-submit loading state visible for one more paint so the spinner appears before navigation work starts. + startSubmitSpans(); + + // Fast path: the Search page was pre-inserted under the RHP (see useEffect above). + // Dismiss the RHP immediately so the user sees the Search page, then run the + // heavy createTransaction work in the next frame - "dismiss first, compute later". + // Reserve the deferred write channel synchronously so that the Search component + // always sees hasDeferredWrite=true on mount (on iOS, rAF fires after + // startTransition resolves, so without the reservation Search would mount first). + if (Navigation.getIsFullscreenPreInsertedUnderRHP()) { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); + Navigation.clearFullscreenPreInsertedFlag(); + reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + Navigation.dismissModal(); requestAnimationFrame(() => { + createTransaction(listOfParticipants); setIsConfirming(false); }); - }); + } else { + requestAnimationFrame(() => { + createTransaction(listOfParticipants); + requestAnimationFrame(() => { + setIsConfirming(false); + }); + }); + } }; /** @@ -1477,11 +1543,13 @@ function IOURequestStepConfirmation({ startPermissionFlow={startLocationPermissionFlow} resetPermissionFlow={() => setStartLocationPermissionFlow(false)} onGrant={() => { + startSubmitSpans(); navigateAfterInteraction(() => { createTransaction(selectedParticipantList, true); }); }} onDeny={() => { + startSubmitSpans(); updateLastLocationPermissionPrompt(); navigateAfterInteraction(() => { createTransaction(selectedParticipantList, false); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 72f6fabdd6407..f0e0c683fc6b5 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -145,6 +145,9 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ getReportRouteByID: jest.fn(), getActiveRouteWithoutParams: jest.fn(), getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), navigationRef: { getRootState: jest.fn(), isReady: jest.fn(() => true), diff --git a/tests/ui/SearchPageTest.tsx b/tests/ui/SearchPageTest.tsx index f18684bfa99ae..b2738d95ccd79 100644 --- a/tests/ui/SearchPageTest.tsx +++ b/tests/ui/SearchPageTest.tsx @@ -27,7 +27,7 @@ jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@react-navigation/core', () => ({ ...jest.requireActual('@react-navigation/core'), - useNavigation: jest.fn(() => ({getState: jest.fn(() => undefined)})), + useNavigation: jest.fn(() => ({getState: jest.fn(() => undefined), isFocused: jest.fn(() => true)})), })); jest.mock('@react-navigation/native', () => ({ diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index e9d1973ebeade..4b68fd9cdd817 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -80,7 +80,11 @@ jest.mock('@libs/Navigation/Navigation', () => { return { navigate: jest.fn(), goBack: jest.fn(), + dismissModal: jest.fn(), dismissModalWithReport: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), navigationRef: mockRef, }; }); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 724fecf0fd04b..2f2bcbb4d4e36 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -101,12 +101,17 @@ jest.mock('@libs/Navigation/Navigation', () => { params: {}, })), getState: jest.fn(() => ({})), + getRootState: jest.fn(() => ({routes: []})), }; return { navigate: jest.fn(), goBack: jest.fn(), + dismissModal: jest.fn(), dismissModalWithReport: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), navigationRef: mockRef, }; }); diff --git a/tests/unit/deferredLayoutWriteTest.ts b/tests/unit/deferredLayoutWriteTest.ts index 6cd6096d8ac4b..4c4b03ddbed11 100644 --- a/tests/unit/deferredLayoutWriteTest.ts +++ b/tests/unit/deferredLayoutWriteTest.ts @@ -1,5 +1,5 @@ import {AppState} from 'react-native'; -import {cancelDeferredWrite, flushDeferredWrite, getOptimisticWatchKey, hasDeferredWrite, registerDeferredWrite} from '@libs/deferredLayoutWrite'; +import {cancelDeferredWrite, flushDeferredWrite, getOptimisticWatchKey, hasDeferredWrite, registerDeferredWrite, reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; beforeEach(() => { jest.useFakeTimers(); @@ -120,4 +120,49 @@ describe('deferredLayoutWrite', () => { flushDeferredWrite('test'); }); + + it('marks a reserved channel as flushRequested instead of consuming it', () => { + reserveDeferredWriteChannel('test'); + expect(hasDeferredWrite('test')).toBe(true); + + flushDeferredWrite('test'); + + expect(hasDeferredWrite('test')).toBe(true); + }); + + it('executes the real callback immediately when registering on a flush-requested reservation', () => { + reserveDeferredWriteChannel('test'); + flushDeferredWrite('test'); + + const callback = jest.fn(); + registerDeferredWrite('test', callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(hasDeferredWrite('test')).toBe(false); + }); + + it('preserves optimisticWatchKey when flush-requested reservation is consumed', () => { + reserveDeferredWriteChannel('test'); + flushDeferredWrite('test'); + + const callback = jest.fn(); + registerDeferredWrite('test', callback, {optimisticWatchKey: 'transactions_123'}); + + expect(callback).toHaveBeenCalledTimes(1); + expect(hasDeferredWrite('test')).toBe(false); + expect(getOptimisticWatchKey('test')).toBe('transactions_123'); + }); + + it('defers the real callback normally when registering on a reservation that was not flushed', () => { + reserveDeferredWriteChannel('test'); + + const callback = jest.fn(); + registerDeferredWrite('test', callback); + + expect(callback).not.toHaveBeenCalled(); + expect(hasDeferredWrite('test')).toBe(true); + + flushDeferredWrite('test'); + expect(callback).toHaveBeenCalledTimes(1); + }); });