Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -8668,6 +8675,8 @@ const CONST = {

MODAL_EVENTS: {
CLOSED: 'modalClosed',
DISABLE_RHP_ANIMATION: 'disableRHPAnimation',
RESTORE_RHP_ANIMATION: 'restoreRHPAnimation',
},

LIST_BEHAVIOR: {
Expand Down
46 changes: 33 additions & 13 deletions src/components/Search/SearchStaticList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -42,6 +43,7 @@ type SearchStaticListProps = {
queryJSON: SearchQueryJSON;
contentContainerStyle?: StyleProp<ViewStyle>;
onLayout?: () => void;
onDestinationVisible?: (wasListEmpty: boolean, source: 'focus' | 'layout') => void;
};

function StaticActionButton({action}: {action: SearchTransactionAction | undefined}) {
Expand All @@ -64,15 +66,15 @@ 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();
const session = useSession();
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));

const {type, status, sortBy, sortOrder, groupBy} = queryJSON;
const validGroupBy = getValidGroupBy(groupBy);
Expand Down Expand Up @@ -100,6 +102,30 @@ function SearchStaticList({searchResults, queryJSON, contentContainerStyle, onLa
.slice(0, STATIC_LIST_MAX_ITEMS);
})();

// When pre-inserted behind the RHP, the static list mounts before any submit
// action (showPendingExpensePlaceholder = false). On focus (after RHP dismiss),
// check for a pending submit-to-search action and show the placeholder row so
// the user sees immediate feedback while the Search component mounts.
// On subsequent re-focus without a pending action, clear the stale placeholder.
//
// This is also the earliest moment the user sees the static list after the RHP
// dismiss, so end the navigation spans here via onDestinationVisible. The
// onLayout-based span ending can't cover this case because onLayout already
// fired during pre-insert (Phase 1) before any span existed, and the one-shot
// ref guard prevents it from firing again.
useFocusEffect(
useCallback(() => {
const hasPendingAction = getPendingSubmitFollowUpAction()?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH;
if (!showPendingExpensePlaceholder && hasPendingAction) {
setShowPendingExpensePlaceholder(true);
} else if (showPendingExpensePlaceholder && !hasPendingAction) {
setShowPendingExpensePlaceholder(false);
}

onDestinationVisible?.(sortedData.length === 0, 'focus');
}, [showPendingExpensePlaceholder, sortedData.length, onDestinationVisible]),
);

const onPressItem = (item: TransactionListItemType) => {
const backTo = Navigation.getActiveRoute();

Expand Down Expand Up @@ -197,14 +223,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?.();
};

Expand Down Expand Up @@ -264,5 +283,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,
);
64 changes: 39 additions & 25 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]),
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 (
<View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
const styles = useThemeStyles();
const {sidePanelOffset} = useSidePanelState();

// When a fullscreen route is pre-inserted under the RHP, disable the slide-out animation
// so the dismiss reveals the destination instantly. If the pre-insert is later cleaned up
// (user backs out without submitting), restore the default animation for that session.
useEffect(() => {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
OpenDomainSplitActionType,
OpenWorkspaceSplitActionType,
PushActionType,
RemoveFullscreenUnderRHPActionType,
ReplaceActionType,
ReplaceFullscreenUnderRHPActionType,
ToggleSidePanelWithHistoryActionType,
Expand Down Expand Up @@ -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<ParamListBase>,
action: RemoveFullscreenUnderRHPActionType,
configOptions: RouterConfigOptions,
stackRouter: Router<StackNavigationState<ParamListBase>, 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.
Expand Down Expand Up @@ -328,6 +365,7 @@ export {
handleOpenDomainSplitAction,
handlePushFullscreenAction,
handleReplaceFullscreenUnderRHP,
handleRemoveFullscreenUnderRHP,
handleReplaceReportsSplitNavigatorAction,
screensWithEnteringAnimation,
handleToggleSidePanelWithHistoryAction,
Expand Down
Loading
Loading