@@ -57,6 +57,7 @@ import {
5757 isTransactionGroupListItemType ,
5858 isTransactionListItemType ,
5959 isTransactionReportGroupListItemType ,
60+ isTransactionSearchType ,
6061 shouldShowEmptyState ,
6162 shouldShowYear as shouldShowYearUtil ,
6263} from '@libs/SearchUIUtils' ;
@@ -84,7 +85,6 @@ import {useSearchActionsContext, useSearchStateContext} from './SearchContext';
8485import SearchList from './SearchList' ;
8586import type { ReportActionListItemType , SearchListItem , TransactionGroupListItemType , TransactionListItemType , TransactionReportGroupListItemType } from './SearchList/ListItem/types' ;
8687import { SearchScopeProvider } from './SearchScopeProvider' ;
87- import SearchStaticList from './SearchStaticList' ;
8888import SearchTableHeader from './SearchTableHeader' ;
8989import type { SearchColumnType , SearchParams , SearchQueryJSON , SelectedTransactionInfo , SelectedTransactions , SortOrder } from './types' ;
9090
@@ -282,9 +282,14 @@ function Search({
282282 // Flush (not cancel) on unmount so the API.write() still executes if the
283283 // user navigates away before onLayout fires. This also clears the channel,
284284 // preventing a stale hasDeferredWrite() on the next mount.
285+ // Skip when navigate_to_search is pending: the old Search instance is being
286+ // replaced by a new one (route swap), and the new instance will handle the
287+ // flush via its own layout/focus callbacks.
285288 useEffect (
286289 ( ) => ( ) => {
287- flushDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ;
290+ if ( getPendingSubmitFollowUpAction ( ) ?. followUpAction !== CONST . TELEMETRY . SUBMIT_FOLLOW_UP_ACTION . NAVIGATE_TO_SEARCH ) {
291+ flushDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ;
292+ }
288293 if ( rollbackTimeoutRef . current ) {
289294 clearTimeout ( rollbackTimeoutRef . current ) ;
290295 }
@@ -1476,14 +1481,23 @@ function Search({
14761481 searchResults ?. data ,
14771482 ] ) ;
14781483
1479- const onLayout = useCallback ( ( ) => {
1484+ const onLayoutBase = useCallback ( ( ) => {
14801485 hasHadFirstLayout . current = true ;
14811486 onDestinationVisible ?.( isSearchResultsEmptyRef . current , 'layout' ) ;
14821487 endSpanWithAttributes ( CONST . TELEMETRY . SPAN_NAVIGATE_TO_REPORTS , { [ CONST . TELEMETRY . ATTRIBUTE_IS_WARM ] : true } ) ;
1488+ TransitionTracker . runAfterTransitions ( {
1489+ callback : ( ) => flushDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ,
1490+ } ) ;
1491+ } , [ onDestinationVisible ] ) ;
1492+
1493+ // Deferred layout only needs the base work (no scroll handling, no content-ready signal).
1494+ const onDeferredLayout = onLayoutBase ;
1495+
1496+ const onLayout = useCallback ( ( ) => {
1497+ onLayoutBase ( ) ;
14831498 handleSelectionListScroll ( stableSortedData , searchListRef . current ) ;
1484- flushDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ;
14851499 onContentReady ?.( ) ;
1486- } , [ handleSelectionListScroll , stableSortedData , onContentReady , onDestinationVisible ] ) ;
1500+ } , [ onLayoutBase , handleSelectionListScroll , stableSortedData , onContentReady ] ) ;
14871501
14881502 // Must be a ref, not state: cancelNavigationSpans is called during render
14891503 // (inside conditional returns), so using setState would trigger infinite re-renders.
@@ -1516,22 +1530,65 @@ function Search({
15161530 endSpanWithAttributes ( CONST . TELEMETRY . SPAN_NAVIGATE_TO_REPORTS , { [ CONST . TELEMETRY . ATTRIBUTE_IS_WARM ] : true } ) ;
15171531 } , [ ] ) ;
15181532
1533+ // Tracks whether the pending-expense tracking was re-armed on re-focus
1534+ // (subsequent expense creation while Search stays mounted). Used by the
1535+ // effect below to dismiss the overlay once the deferred write completes,
1536+ // since onLayout won't re-fire for already-mounted content.
1537+ const wasRearmedRef = useRef ( false ) ;
1538+
15191539 // On re-visits, react-freeze serves the cached layout — onLayout/onLayoutSkeleton never fire.
15201540 // useFocusEffect fires on unfreeze, which is when the screen becomes visible.
15211541 useFocusEffect (
15221542 useCallback ( ( ) => {
15231543 if ( ! hasHadFirstLayout . current ) {
15241544 return ;
15251545 }
1546+
1547+ // Re-arm pending expense skeleton for subsequent creations while Search
1548+ // stays mounted (the original hasPendingWriteOnMountRef only covers the first).
1549+ if ( hasDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) && ! showPendingExpensePlaceholder ) {
1550+ wasRearmedRef . current = true ;
1551+
1552+ // Let the optimistic tracking effect know it should watch for the new item.
1553+ hasPendingWriteOnMountRef . current = true ;
1554+ optimisticTrackingCleanedUpRef . current = false ;
1555+ setIsOptimisticTrackingCleared ( false ) ;
1556+
1557+ setShowPendingExpensePlaceholder ( true ) ;
1558+ // Prevent the full-page SearchRowSkeleton from rendering (shouldShowRowSkeleton),
1559+ // which would cause a blink as SelectionList unmounts/remounts.
1560+ setSkeletonWasDisplayed ( true ) ;
1561+
1562+ // Clear stale cached item so the new optimistic row is picked up fresh.
1563+ cachedOptimisticItemRef . current = null ;
1564+ const latestKey = getOptimisticWatchKey ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ;
1565+ if ( latestKey ) {
1566+ optimisticWatchKeyRef . current = latestKey ;
1567+ }
1568+ }
1569+
15261570 onDestinationVisible ?.( isSearchResultsEmptyRef . current , 'focus' ) ;
15271571 endSpanWithAttributes ( CONST . TELEMETRY . SPAN_NAVIGATE_TO_REPORTS , {
15281572 [ CONST . TELEMETRY . ATTRIBUTE_IS_WARM ] : ! shouldShowLoadingState ,
15291573 } ) ;
15301574 // On re-focus (e.g. DISMISS_MODAL_ONLY) onLayout won't re-fire — flush here.
15311575 flushDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ;
1532- } , [ shouldShowLoadingState , onDestinationVisible ] ) ,
1576+ } , [ shouldShowLoadingState , onDestinationVisible , showPendingExpensePlaceholder ] ) ,
15331577 ) ;
15341578
1579+ // Dismiss the overlay after a re-armed deferred write completes. On re-focus,
1580+ // onLayout doesn't re-fire (content already mounted), so onContentReady is
1581+ // never called via the normal path. This effect detects when the deferred
1582+ // write channel is gone (write executed) and sortedData has updated, then
1583+ // signals overlay readiness.
1584+ useEffect ( ( ) => {
1585+ if ( ! wasRearmedRef . current || hasDeferredWrite ( CONST . DEFERRED_LAYOUT_WRITE_KEYS . SEARCH ) ) {
1586+ return ;
1587+ }
1588+ wasRearmedRef . current = false ;
1589+ onContentReady ?.( ) ;
1590+ } , [ sortedData , onContentReady ] ) ;
1591+
15351592 // Reset before conditional returns. Only cancelNavigationSpans (error/empty paths)
15361593 // sets it to true. Must happen during render since it coordinates with the
15371594 // dep-free useEffect above — see comment on didBailToFallbackState.
@@ -1579,25 +1636,14 @@ function Search({
15791636 ) ;
15801637
15811638 // When heavy work is deferred (e.g. during the RHP dismiss animation after
1582- // submitting an expense), show a lightweight static list instead of the skeleton.
1583- // This gives the user real-looking content during the animation while avoiding
1584- // the expensive hooks and renders of the full Search component.
1585- // Restricted to transaction-based search types (expense/invoice) because
1586- // SearchStaticList only renders rows with a transactionID - non-transaction
1587- // types (chat, task, report) would render empty/blank during the deferral.
1588- const isTransactionSearchType = type === CONST . SEARCH . DATA_TYPES . EXPENSE || type === CONST . SEARCH . DATA_TYPES . INVOICE ;
1589- if ( isDeferringHeavyWork && searchResults ?. data && isTransactionSearchType ) {
1590- return (
1591- < SearchStaticList
1592- searchResults = { searchResults }
1593- queryJSON = { queryJSON }
1594- shouldUseNarrowLayout = { shouldUseNarrowLayout }
1595- canSelectMultiple = { canSelectMultiple }
1596- columns = { currentColumns }
1597- contentContainerStyle = { shouldUseNarrowLayout ? styles . searchListContentContainerStyles ( ! ! hasFilterBars ) : undefined }
1598- onLayout = { onLayout }
1599- />
1600- ) ;
1639+ // submitting an expense), skip the expensive render below. The ancestor
1640+ // SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList
1641+ // overlay that covers this component, so the user sees real-looking content.
1642+ // The minimal View fires onLayout to flush the deferred API write and set
1643+ // hasHadFirstLayout.
1644+ if ( isDeferringHeavyWork && searchResults ?. data && isTransactionSearchType ( type ) ) {
1645+ // Zero-sized View - onLayout still fires on RN, which is all we need here.
1646+ return < View onLayout = { onDeferredLayout } /> ;
16011647 }
16021648
16031649 // This is a performance optimization for the submit-expense->search path only.
@@ -1768,7 +1814,7 @@ function Search({
17681814 shouldAnimate
17691815 fixedNumItems = { shouldShowLoadingMoreItems ? 5 : 1 }
17701816 reasonAttributes = { showPendingExpensePlaceholder ? pendingExpenseReasonAttributes : loadMoreSkeletonReasonAttributes }
1771- isLoadMore = { shouldShowLoadingMoreItems }
1817+ isLoadMore
17721818 />
17731819 ) : undefined
17741820 }
0 commit comments