From e207f04c994bb76ee9640f3c626f2f0c8cc535a9 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 27 May 2026 11:34:37 +0700 Subject: [PATCH 01/16] feat: Standardize Expense Report and Expense header --- src/components/MoneyReportHeader.tsx | 47 +++++++++--- .../MoneyReportHeaderMoreContent.tsx | 45 ++++++++---- src/components/MoneyRequestHeader.tsx | 32 +++++--- .../MoneyRequestReportNavigation.tsx | 6 +- .../MoneyRequestReportTransactionList.tsx | 6 ++ ...neyRequestReportTransactionsNavigation.tsx | 73 ++++++++++++++----- src/components/Search/index.tsx | 16 ++++ src/languages/de.ts | 1 + src/languages/en.ts | 2 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/styles/utils/sizing.ts | 4 + 18 files changed, 184 insertions(+), 56 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2513cdb928e2..172b61c0c958 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -23,6 +23,8 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; +import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; @@ -74,14 +76,24 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactions = Object.values(reportTransactions); + const [activeTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + + // When the user opens a one-transaction parent report from the flat Spend > Expenses list, the report-level + // carousel (search-based) is inactive (search type is EXPENSE, not EXPENSE_REPORT). Fall back to the + // transaction carousel, anchored on the parent's single transaction, so navigating ◄/► pages through the + // expenses the user was browsing. The carousel itself handles routing to either the parent report (for + // other one-tx parents) or the transaction thread (for multi-tx parents). + const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; + const shouldShowTransactionNavigation = !!singleTransactionID && !!activeTransactionIDs?.includes(singleTransactionID); + const styles = useThemeStyles(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldShowHeaderButtonsInHeaderRow = isInLandscapeMode || !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const isReportInRHP = route.name !== SCREENS.REPORT; - const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; + const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !isReportInSearch); const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; @@ -131,24 +143,35 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - {shouldShowHeaderButtonsInHeaderRow && ( + {isReportInSearch && + (shouldShowTransactionNavigation && singleTransactionID ? ( + + ) : ( + + ))} + + {!shouldShowHeaderButtonsInHeaderRow && ( + - )} - - {!shouldShowHeaderButtonsInHeaderRow && ( - + )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index eeb69cf959f4..c6fb2b17df42 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -8,8 +8,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; @@ -19,21 +17,26 @@ import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import MoneyReportHeaderActions from './MoneyReportHeaderActions'; +import type {MoneyReportHeaderActionsProps} from './MoneyReportHeaderActions/types'; import MoneyReportHeaderNextStep from './MoneyReportHeaderNextStep'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; -import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; type MoneyReportHeaderMoreContentProps = { reportID: string | undefined; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldShowHeaderButtonsInHeaderRow: boolean; }; /** * Cheap visibility gate — fetches minimal data to decide whether the more-content section * should render at all, avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, shouldShowHeaderButtonsInHeaderRow}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -50,8 +53,10 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const shouldShowNextStep = isFromPaidPolicy && !isInvoiceReport && !shouldShowStatusBar; + const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; + const shouldRenderActionsInRow = shouldShowHeaderButtonsInHeaderRow; - const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; + const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; if (!shouldShowMoreContent) { return null; @@ -63,6 +68,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} + primaryAction={primaryAction} + backTo={backTo} + shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -72,15 +80,22 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({ + moneyRequestReport, + statusBarType, + isReportInSearch, + shouldShowNextStep, + primaryAction, + backTo, + shouldRenderActionsInRow, +}: MoneyReportHeaderMoreContentBodyProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; - const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); - const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const reportID = moneyRequestReport?.reportID; const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); @@ -101,7 +116,7 @@ function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, is const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; return ( - + {shouldShowNextStep && } - {isReportInSearch && ( - )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index c2642e972467..a0abb3c45791 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -93,6 +93,7 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP); const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine() && (wideRHPRouteKeys.length === 0 || isSmallScreenWidth); + const shouldDisplayNarrowVersion = shouldDisplayButtonsInSeparateLine; const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( - {!shouldDisplayButtonsInSeparateLine && ( + {!shouldDisplayButtonsInSeparateLine && !statusBarProps && ( )} {shouldDisplayButtonsInSeparateLine && ( - + + + )} {!!statusBarProps && ( - - + + + + + {!shouldDisplayButtonsInSeparateLine && ( + + )} )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 580e596ff222..023c211fba7c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -5,6 +5,7 @@ import PrevNextButtons from '@components/PrevNextButtons'; import {useSearchResultsContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -81,6 +82,7 @@ const buildSnapshotGuardSelector = function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, allReports, isSearchLoading, lastSearchQuery}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const liveCurrentIndex = allReports.indexOf(reportID); @@ -190,7 +192,9 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi return ( - {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} + {!shouldDisplayNarrowVersion && ( + {translate('common.currentOfTotal', {current: currentIndex + 1, total: allReportsCount})} + )} Expenses list, the search-list IDs were already set by Search/index.tsx onSelectRow + // and they back the carousel; overwriting with just this report's single transaction would collapse it. + if (visualOrderTransactionIDs.length < 2) { + return; + } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index b4c41f0352ed..5144dcefb845 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,15 +1,20 @@ import {findFocusedRoute} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo} from 'react'; import type {GestureResponderEvent} from 'react-native'; +import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import PrevNextButtons from '@components/PrevNextButtons'; +import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isOneTransactionReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,6 +25,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; type MoneyRequestReportRHPNavigationButtonsProps = { currentTransactionID: string; isFromReviewDuplicates?: boolean; + shouldDisplayNarrowVersion?: boolean; }; const parentReportActionIDsSelector = (reportActions: OnyxEntry) => { @@ -34,20 +40,22 @@ const parentReportActionIDsSelector = (reportActions: OnyxEntry()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {markReportIDAsExpense} = useWideRHPActions(); + const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); + const {prevTransactionID, nextTransactionID} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { return {prevTransactionID: undefined, nextTransactionID: undefined}; } - const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); - const prevID = currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined; const nextID = transactionIDsList.at(currentTransactionIndex + 1); @@ -55,7 +63,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR prevTransactionID: prevID, nextTransactionID: nextID, }; - }, [currentTransactionID, transactionIDsList]); + }, [currentTransactionIndex, transactionIDsList]); const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => @@ -96,6 +104,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentTransaction?.reportID}`); const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + const [prevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); + const [nextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -115,15 +125,30 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR return; } - const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { - e?.preventDefault(); - + const getBackTo = () => { let backTo = Navigation.getActiveRoute(); if (isFromReviewDuplicates) { const currentRoute = navigationRef.getCurrentRoute(); const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; backTo = params?.backTo ?? backTo; } + return backTo; + }; + + const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { + e?.preventDefault(); + const backTo = getBackTo(); + + // If the next expense's parent is a one-transaction report, navigate to the parent report instead of the + // thread. This keeps the view at the same level (parent) so report-level primary actions (Approve, etc.) + // are preserved when navigating back. Mirrors the open-from-list logic in Search/index.tsx#onSelectRow. + if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID) { + const targetReportID = nextTransaction.reportID; + markReportIDAsExpense(targetReportID); + requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo})); + return; + } + const nextThreadReportID = nextParentReportAction?.childReportID; const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, backTo}; @@ -153,13 +178,16 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { e?.preventDefault(); - - let backTo = Navigation.getActiveRoute(); - if (isFromReviewDuplicates) { - const currentRoute = navigationRef.getCurrentRoute(); - const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; - backTo = params?.backTo ?? backTo; + const backTo = getBackTo(); + + // See onNext for the rationale behind the one-transaction-parent branch. + if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID) { + const targetReportID = prevTransaction.reportID; + markReportIDAsExpense(targetReportID); + requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo})); + return; } + const prevThreadReportID = prevParentReportAction?.childReportID; const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, backTo}; @@ -188,12 +216,19 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR }; return ( - + + {!shouldDisplayNarrowVersion && currentTransactionIndex !== -1 && ( + + {translate('common.currentOfTotal', {current: currentTransactionIndex + 1, total: transactionIDsList.length})} + + )} + + ); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3ab568e6bd2b..4f0e2e845f0a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -33,6 +33,7 @@ import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actio import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; +import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -1101,6 +1102,20 @@ function Search({ const isTransactionItem = isTransactionListItemType(item); const backTo = Navigation.getActiveRoute(); + + // When opening an expense from the Spend page (flat transaction list), populate the carousel + // with all sibling transactions so prev/next navigation works in the RHP transaction view. + if (isTransactionItem) { + const siblingTransactionIDs = (filteredData as TransactionListItemType[]) + .filter((t) => !!t && 'transactionID' in t && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((t) => t.transactionID); + if (siblingTransactionIDs.length > 1) { + setActiveTransactionIDs(siblingTransactionIDs); + } else { + clearActiveTransactionIDs(); + } + } + // If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user if (isTransactionItem && !item?.reportAction?.childReportID) { // If the report is unreported (self DM), we want to open the track expense thread instead of a report with an ID of 0 @@ -1260,6 +1275,7 @@ function Search({ offset, searchResults?.search?.hasMoreResults, currentSearchKey, + filteredData, ], ); diff --git a/src/languages/de.ts b/src/languages/de.ts index df348577e04e..f080af0f1db5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -499,6 +499,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} von ${total}`, }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/en.ts b/src/languages/en.ts index d7dd0008a741..65b56518bc07 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -358,6 +358,8 @@ const translations = { automatic: 'Automatic', showing: 'Showing', of: 'of', + // @context Carousel pagination counter showing the current item's position out of the total (e.g. "3 of 50"). + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} of ${total}`, default: 'Default', update: 'Update', member: 'Member', diff --git a/src/languages/es.ts b/src/languages/es.ts index f4f7b110649a..111ef85dd595 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -451,6 +451,7 @@ const translations: TranslationDeepObject = { expensifyLogo: 'Logo de Expensify', approver: 'Aprobador', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, }, socials: { podcast: 'Síguenos en Podcast', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cbe3ce6ae349..aabaa8770790 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -499,6 +499,7 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} sur ${total}`, }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8f4bebe7811f..ef851b1d577f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -499,6 +499,7 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} di ${total}`, }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9a338d53e906..78a9a13c9001 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -498,6 +498,7 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${total} 件中 ${current} 件目`, }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 136295ba5550..68a00a84065b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -498,6 +498,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} van ${total}`, }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 05620f567074..3475552abaf1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -498,6 +498,7 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} z ${total}`, }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9e6bcc16a592..1b67c6608d85 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -497,6 +497,7 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 10cf77ec22a0..ef25071175d2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -494,6 +494,7 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', + currentOfTotal: ({current, total}: {current: number; total: number}) => `第 ${current} 项(共 ${total} 项)`, }, socials: { podcast: '在播客上关注我们', diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 2940585f8fe8..85b0591fc364 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -84,6 +84,10 @@ export default { minWidth: 8, }, + mnw8: { + minWidth: 32, + }, + mnw25: { minWidth: '25%', }, From 2ff6f59a7726c099a36d044b977487d0acfa2ff8 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 27 May 2026 22:52:46 +0700 Subject: [PATCH 02/16] fix: carousel issue --- src/components/MoneyReportHeader.tsx | 28 ++++++-- .../MoneyRequestReportTransactionList.tsx | 19 ++++- ...neyRequestReportTransactionsNavigation.tsx | 72 +++++++++---------- src/libs/Navigation/types.ts | 4 ++ src/pages/inbox/ReportNavigateAwayHandler.tsx | 12 ++-- 5 files changed, 85 insertions(+), 50 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 172b61c0c958..75467faee811 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -84,7 +84,27 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt // expenses the user was browsing. The carousel itself handles routing to either the parent report (for // other one-tx parents) or the transaction thread (for multi-tx parents). const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; - const shouldShowTransactionNavigation = !!singleTransactionID && !!activeTransactionIDs?.includes(singleTransactionID); + + // For multi-tx parents we don't have a single transaction to anchor on, but if the parent was + // navigated to from a broader carousel (the no-thread fallback in MoneyRequestReportTransactionsNavigation + // passes `anchorTransactionID`), use that transaction as the carousel anchor so the user can keep + // paging the broader list. Falls back to the first of this report's transactions found in the active + // list, so the carousel still renders even without an explicit hint. + const anchorTransactionIDFromRoute = (route.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; + const multiTxAnchorTransactionID = useMemo(() => { + if (singleTransactionID) { + return undefined; + } + if (anchorTransactionIDFromRoute && transactions.some((t) => t.transactionID === anchorTransactionIDFromRoute) && activeTransactionIDs?.includes(anchorTransactionIDFromRoute)) { + return anchorTransactionIDFromRoute; + } + if (!activeTransactionIDs) { + return undefined; + } + return transactions.find((t) => activeTransactionIDs.includes(t.transactionID))?.transactionID; + }, [singleTransactionID, anchorTransactionIDFromRoute, transactions, activeTransactionIDs]); + const carouselAnchorTransactionID = singleTransactionID ?? multiTxAnchorTransactionID; + const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); const styles = useThemeStyles(); @@ -144,9 +164,9 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt openParentReportInCurrentTab > {isReportInSearch && - (shouldShowTransactionNavigation && singleTransactionID ? ( + (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( ) : ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 5e2c7979e0db..c00bc0989dbe 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -394,6 +394,12 @@ function MoneyRequestReportTransactionList({ // the effect only re-fires when the actual content changes. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); + // Subscribe so the effect closure captures the latest active list. Used to detect when this report's + // transactions are a strict subset of a broader carousel (e.g. the user landed here from the flat + // Spend > Expenses carousel via the no-thread fallback in MoneyRequestReportTransactionsNavigation) + // and skip overwriting in that case. + const [latestActiveTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { @@ -405,11 +411,22 @@ function MoneyRequestReportTransactionList({ if (visualOrderTransactionIDs.length < 2) { return; } + // Same reasoning for multi-tx parents: if the existing carousel already contains every transaction + // from this report (i.e. is a strict superset), we arrived here from a broader carousel — likely + // via the no-thread fallback in MoneyRequestReportTransactionsNavigation. Overwriting would shrink + // the active list to just this report and the user would lose their place in the broader carousel. + if ( + latestActiveTransactionIDs && + latestActiveTransactionIDs.length > visualOrderTransactionIDs.length && + visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id)) + ) { + return; + } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere }, [visualOrderTransactionIDsKey]); const groupSelectionState = useMemo(() => { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 5144dcefb845..c1f5838dc817 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -6,11 +6,10 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import PrevNextButtons from '@components/PrevNextButtons'; import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -44,9 +43,6 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const styles = useThemeStyles(); const {translate} = useLocalize(); const [transactionIDsList = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {markReportIDAsExpense} = useWideRHPActions(); const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); @@ -152,28 +148,34 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const nextThreadReportID = nextParentReportAction?.childReportID; const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, backTo}; + // No existing transaction thread for this IOU action. Do NOT create one optimistically here: + // the carousel uses Navigation.setParams (mutating the modal's params + remounting via key), + // which races with the in-flight OpenReport call. The server doesn't recognize the synthetic + // optimistic reportID, the local report is wiped, and ReportNavigateAwayHandler bounces the + // user to ROUTES.REPORT_WITH_ID (Inbox/parent). Instead, fall back to navigating to the next + // transaction's parent report. We pass anchorTransactionID so MoneyReportHeader can keep the + // transaction carousel anchored on the user's intended next transaction. + if (!nextThreadReportID && nextTransaction?.reportID) { + const targetReportID = nextTransaction.reportID; + markReportIDAsExpense(targetReportID); + requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo})); + return; + } + if (nextThreadReportID) { markReportIDAsExpense(nextThreadReportID); } // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. + // Important: use nextTransactionParentReport (the NEXT transaction's own parent), NOT parentReport + // (the CURRENT transaction's parent). Passing wrong linkage causes the OpenReport response to wipe + // the optimistic data, which trips useReportWasDeleted → ReportNavigateAwayHandler → Inbox/parent redirect. if (!nextThreadReport && nextThreadReportID) { - setOptimisticTransactionThread(nextThreadReportID, parentReport?.reportID, nextParentReportAction?.reportActionID, parentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!nextThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: parentReport, - iouReportAction: nextParentReportAction, - transaction: nextTransaction, - }); - navigationParams.reportID = transactionThreadReport?.reportID; + setOptimisticTransactionThread(nextThreadReportID, nextTransactionParentReport?.reportID, nextParentReportAction?.reportActionID, nextTransactionParentReport?.policyID); } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating - requestAnimationFrame(() => Navigation.setParams(navigationParams)); + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating + requestAnimationFrame(() => { + Navigation.setParams(navigationParams); + }); }; const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { @@ -191,27 +193,23 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const prevThreadReportID = prevParentReportAction?.childReportID; const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, backTo}; + // See onNext for the rationale behind this fallback. + if (!prevThreadReportID && prevTransaction?.reportID) { + const targetReportID = prevTransaction.reportID; + markReportIDAsExpense(targetReportID); + requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo})); + return; + } + if (prevThreadReportID) { markReportIDAsExpense(prevThreadReportID); } - // We know that the previous thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. + // See onNext for the rationale: use prevTransactionParentReport (the PREV transaction's own parent) + // instead of parentReport (the CURRENT transaction's parent) so the optimistic linkage matches the server. if (!prevThreadReport && prevThreadReportID) { - setOptimisticTransactionThread(prevThreadReportID, parentReport?.reportID, prevParentReportAction?.reportActionID, parentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!prevThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: parentReport, - iouReportAction: prevParentReportAction, - transaction: prevTransaction, - }); - navigationParams.reportID = transactionThreadReport?.reportID; + setOptimisticTransactionThread(prevThreadReportID, prevTransactionParentReport?.reportID, prevParentReportAction?.reportActionID, prevTransactionParentReport?.policyID); } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating requestAnimationFrame(() => Navigation.setParams(navigationParams)); }; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3a3d2525d0e6..427f7d5a4b96 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2556,6 +2556,10 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { reportID: string; reportActionID?: string; + // Set by the transaction carousel when navigating into a multi-tx parent that lacks an + // existing thread. Tells MoneyReportHeader which of the parent's transactions to anchor + // the transaction carousel on, so the user can keep paging through the broader carousel. + anchorTransactionID?: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index c0502f05d4f6..69bbcc0ef485 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -154,15 +154,11 @@ function ReportNavigateAwayHandler() { const didReportClose = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const isTopLevelPolicyRoomWithNoStatus = !report?.statusNum && !prevReport?.parentReportID && prevReport?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; const isClosedTopLevelPolicyRoom = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && isTopLevelPolicyRoomWithNoStatus; + const userLeavingTriggered = !prevUserLeavingStatus && !!userLeavingStatus; + const deletedParentTriggered = prevDeletedParentAction && !deletedParentAction; + const shouldTrigger = userLeavingTriggered || didReportClose || isRemovalExpectedForReportType || isClosedTopLevelPolicyRoom || deletedParentTriggered; // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) - if ( - // non-optimistic case - (!prevUserLeavingStatus && !!userLeavingStatus) || - didReportClose || - isRemovalExpectedForReportType || - isClosedTopLevelPolicyRoom || - (prevDeletedParentAction && !deletedParentAction) - ) { + if (shouldTrigger) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); } }, [ From a6ce54d23730ecc5771709d1ef3a613d219c6b3e Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 28 May 2026 00:15:57 +0700 Subject: [PATCH 03/16] fix: lint check --- .../MoneyRequestReportTransactionsNavigation.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index c1f5838dc817..fb72e6fd7864 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -97,7 +97,6 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentTransaction?.reportID}`); const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); const [prevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); From 57344316d558811e93172feb9dfcdd3ec45fecb8 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:39:11 +0700 Subject: [PATCH 04/16] fix: conflicts --- src/languages/de.ts | 3 --- src/languages/es.ts | 3 --- src/languages/fr.ts | 3 --- src/languages/it.ts | 3 --- src/languages/ja.ts | 3 --- src/languages/nl.ts | 3 --- src/languages/pl.ts | 3 --- src/languages/pt-BR.ts | 3 --- src/languages/zh-hans.ts | 3 --- 9 files changed, 27 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index a1db3010a53f..a194aed655ea 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -497,11 +497,8 @@ const translations: TranslationDeepObject = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} von ${total}`, -======= restrictions: 'Beschränkungen', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/es.ts b/src/languages/es.ts index a759af50a90a..141087203f74 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -449,11 +449,8 @@ const translations: TranslationDeepObject = { expensifyLogo: 'Logo de Expensify', approver: 'Aprobador', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, -======= restrictions: 'Restricciones', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Síguenos en Podcast', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2c127cce8bf3..59c231fc4ea7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -497,11 +497,8 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} sur ${total}`, -======= restrictions: 'Restrictions', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index d01ea8c463d2..d87a826e4af6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -497,11 +497,8 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} di ${total}`, -======= restrictions: 'Restrizioni', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5ce0f0dc8b3e..4ea8a76e3a43 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -496,11 +496,8 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${total} 件中 ${current} 件目`, -======= restrictions: '制限', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1d9d59c5df0c..dcb32b4b5db1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -496,11 +496,8 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} van ${total}`, -======= restrictions: 'Beperkingen', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c7548ad993a4..a57f645e561d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -496,11 +496,8 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} z ${total}`, -======= restrictions: 'Ograniczenia', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 804cae1524a0..efe445519167 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -495,11 +495,8 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, -======= restrictions: 'Restrições', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e56fab80501c..151ea98ce102 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -492,11 +492,8 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', -<<<<<<< HEAD currentOfTotal: ({current, total}: {current: number; total: number}) => `第 ${current} 项(共 ${total} 项)`, -======= restrictions: '限制', ->>>>>>> 87d567f81371627a494dd0fca541a5fded73516d }, socials: { podcast: '在播客上关注我们', From fc2feef8067a2b0143ca99b197f523c120744b7a Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:10:12 -0500 Subject: [PATCH 05/16] fix: update to use snapshot data for carousel --- src/ONYXKEYS.ts | 5 ++ src/components/MoneyReportHeader.tsx | 5 +- .../MoneyRequestReportTransactionList.tsx | 8 +++ ...neyRequestReportTransactionsNavigation.tsx | 63 ++++++++++++++----- .../ListItem/TransactionGroupListExpanded.tsx | 4 +- src/components/Search/index.tsx | 3 +- .../actions/TransactionThreadNavigation.ts | 19 ++++-- 7 files changed, 84 insertions(+), 23 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 451db5291bb9..a6d27d85245b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -718,6 +718,10 @@ const ONYXKEYS = { /** List of transaction IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS: 'transactionThreadNavigationTransactionIDs', + /** Hash of the search snapshot that holds the transactions referenced by TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. + * Used to fall back to snapshot data when the live transaction collection hasn't loaded those transactions yet (e.g. opening an expense from the Spend page as an approver). */ + TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH: 'transactionThreadNavigationSnapshotHash', + REPORT_NAVIGATION_LAST_SEARCH_QUERY: 'ReportNavigationLastSearchQuery', /** Timestamp of the last login on iOS */ @@ -1652,6 +1656,7 @@ type OnyxValuesMapping = { [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS]: string[]; + [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH]: number; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; [ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION]: OnboardingAccounting; [ONYXKEYS.HYBRID_APP]: OnyxTypes.HybridApp; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 75467faee811..4c539067ffd5 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -95,7 +95,10 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt if (singleTransactionID) { return undefined; } - if (anchorTransactionIDFromRoute && transactions.some((t) => t.transactionID === anchorTransactionIDFromRoute) && activeTransactionIDs?.includes(anchorTransactionIDFromRoute)) { + // Trust the route hint as long as it's part of the active carousel list. We don't also require it to be in + // this report's `transactions` because when arriving from a search-based carousel the parent report's + // transactions may not be in the live collection yet, which would otherwise hide the carousel entirely. + if (anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute)) { return anchorTransactionIDFromRoute; } if (!activeTransactionIDs) { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index fbbb71eb9921..c72d1c35f7cc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -476,6 +476,14 @@ function MoneyRequestReportTransactionList({ if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } + // If we arrived from a broader (search) carousel anchored on a specific transaction — the no-thread fallback + // in MoneyRequestReportTransactionsNavigation passes `anchorTransactionID` — keep that carousel's list (and its + // snapshot context) intact. The superset check below only covers reports fully contained in the active list; + // this also covers reports whose transactions are only partially present in the search results. + const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; + if (anchorTransactionID && latestActiveTransactionIDs?.includes(anchorTransactionID)) { + return; + } // Don't overwrite active transaction IDs for one-transaction parent reports. When such a report is opened // from the flat Spend > Expenses list, the search-list IDs were already set by Search/index.tsx onSelectRow // and they back the carousel; overwriting with just this report's single transaction would collapse it. diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index fb72e6fd7864..ec3f0dc80028 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -16,6 +16,7 @@ import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils import {isOneTransactionReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -43,6 +44,11 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const styles = useThemeStyles(); const {translate} = useLocalize(); const [transactionIDsList = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + // When the carousel is opened from a search (e.g. the Spend page), the sibling transactions may only exist + // in the search snapshot and not in the live collection yet. We keep the snapshot around to fall back to it + // so prev/next navigation resolves the correct report instead of breaking. + const [snapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); + const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`); const {markReportIDAsExpense} = useWideRHPActions(); const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); @@ -63,8 +69,11 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - [currentTransactionID, nextTransactionID, prevTransactionID], + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + return allTransactions?.[key] ?? snapshot?.data?.[key]; + }), + [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], ); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { @@ -74,12 +83,24 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { let reportActions = {}; + // Reported transactions keep their IOU action on their own report (reportActions_). for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { - reportActions = {...reportActions, ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`]}; + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; + reportActions = {...reportActions, ...(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined))}; + } + // Unreported transactions (reportID '0') keep their IOU action on a different report (e.g. a self-DM), + // so it isn't under reportActions_0. Merge every report's actions from the search snapshot so the action + // (and its childReportID thread) can still be located by IOUTransactionID. + if (snapshot?.data) { + for (const [key, reportActionsForReport] of Object.entries(snapshot.data)) { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { + reportActions = {...reportActions, ...(reportActionsForReport as OnyxTypes.ReportActions)}; + } + } } return parentReportActionIDsSelector(reportActions); }, - [currentTransaction, nextTransaction, prevTransaction], + [currentTransaction, nextTransaction, prevTransaction, snapshot], ); const [parentReportActions = new Map()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { @@ -97,10 +118,22 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); - const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); - const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); - const [prevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); - const [nextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); + // The "parent report" is where the transaction's IOU action lives: the expense report for reported transactions, + // or a self-DM for unreported ones (whose transaction.reportID is '0'). Derive it from the action so unreported + // transactions resolve to the correct parent instead of report '0'. Fall back to the transaction's reportID. + const prevParentReportID = prevParentReportAction?.reportID ?? prevTransaction?.reportID; + const nextParentReportID = nextParentReportAction?.reportID ?? nextTransaction?.reportID; + + const [livePrevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); + const [liveNextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + const [livePrevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`); + const [liveNextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`); + + // Fall back to the search snapshot for reports that aren't in the live collection yet. + const prevThreadReport = livePrevThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`]; + const nextThreadReport = liveNextThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`]; + const prevTransactionParentReport = livePrevTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`]; + const nextTransactionParentReport = liveNextTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`]; /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -137,7 +170,9 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // If the next expense's parent is a one-transaction report, navigate to the parent report instead of the // thread. This keeps the view at the same level (parent) so report-level primary actions (Approve, etc.) // are preserved when navigating back. Mirrors the open-from-list logic in Search/index.tsx#onSelectRow. - if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID) { + // Skip for unreported transactions (reportID '0'): they have no parent report to land on, so they must open + // their transaction thread (handled below). + if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { const targetReportID = nextTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo})); @@ -154,7 +189,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // user to ROUTES.REPORT_WITH_ID (Inbox/parent). Instead, fall back to navigating to the next // transaction's parent report. We pass anchorTransactionID so MoneyReportHeader can keep the // transaction carousel anchored on the user's intended next transaction. - if (!nextThreadReportID && nextTransaction?.reportID) { + if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { const targetReportID = nextTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo})); @@ -181,8 +216,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR e?.preventDefault(); const backTo = getBackTo(); - // See onNext for the rationale behind the one-transaction-parent branch. - if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID) { + // See onNext for the rationale behind the one-transaction-parent branch (and the unreported skip). + if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { const targetReportID = prevTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo})); @@ -192,8 +227,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const prevThreadReportID = prevParentReportAction?.childReportID; const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, backTo}; - // See onNext for the rationale behind this fallback. - if (!prevThreadReportID && prevTransaction?.reportID) { + // See onNext for the rationale behind this fallback (and the unreported skip). + if (!prevThreadReportID && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { const targetReportID = prevTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo})); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 44be71152603..ad6590d0f684 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -216,12 +216,12 @@ function TransactionGroupListExpanded({ // When opening the transaction thread in RHP we need to find every other ID for the rest of transactions // to display prev/next arrows in RHP for navigation if (isModifiedMousePress(event)) { - setActiveTransactionIDs(siblingTransactionIDs); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash); navigateToTransactionThread(); return; } - setActiveTransactionIDs(siblingTransactionIDs).then(navigateToTransactionThread); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash).then(navigateToTransactionThread); }; const onShowMoreButtonPress = () => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 90ae54a13aba..bc87835dd638 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1202,7 +1202,7 @@ function Search({ .filter((t) => !!t && 'transactionID' in t && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((t) => t.transactionID); if (siblingTransactionIDs.length > 1) { - setActiveTransactionIDs(siblingTransactionIDs); + setActiveTransactionIDs(siblingTransactionIDs, hash); } else { clearActiveTransactionIDs(); } @@ -1364,6 +1364,7 @@ function Search({ email, accountID, queryJSON, + hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 2c64d9a62c0d..f363475efb73 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -10,23 +10,32 @@ import ONYXKEYS from '@src/ONYXKEYS'; */ let lastSetIDs: string[] | null = null; +let lastSetSnapshotHash: number | null = null; /** - * Idempotent: skips the Onyx write when the IDs haven't changed. + * Idempotent: skips the Onyx write when the IDs (and snapshot hash) haven't changed. * This lets callers (e.g. useEffect in MoneyRequestReportTransactionList) fire * freely without worrying about referential equality of the input array. + * + * When the navigation list originates from a search, pass the search snapshot hash so the + * transaction RHP carousel can fall back to snapshot data for transactions that aren't in the + * live collection yet (e.g. an approver opening an expense from the Spend page). */ -function setActiveTransactionIDs(ids: string[]) { - if (lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i))) { +function setActiveTransactionIDs(ids: string[], snapshotHash?: number) { + const nextSnapshotHash = snapshotHash ?? null; + const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); + if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash) { return Promise.resolve(); } lastSetIDs = ids; - return Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids); + lastSetSnapshotHash = nextSnapshotHash; + return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, nextSnapshotHash)]); } function clearActiveTransactionIDs() { lastSetIDs = null; - return Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null); + lastSetSnapshotHash = null; + return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, null)]); } export {setActiveTransactionIDs, clearActiveTransactionIDs}; From 76b4167d0cad76b3e82d9d5f2b50613b8f7f8883 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:55:59 -0500 Subject: [PATCH 06/16] fix: carousel --- .../MoneyRequestReportTransactionList.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 18d6ccc8cfb8..26878faf2efc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -486,12 +486,15 @@ function MoneyRequestReportTransactionList({ return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. - // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same - // Onyx key with a different ordering. Using the raw array reference would cause the effect - // to re-fire on every referential change and overwrite those IDs. The joined string ensures - // the effect only re-fires when the actual content changes. - const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); + // Membership proxy for visualOrderTransactionIDs used as the effect dependency below. + // We key on the *sorted set* of IDs (not their visual order) so the effect only re-fires when a + // transaction is added or removed - not when an edit (e.g. changing an expense's date or category) + // re-sorts the parent list. This keeps the carousel's stored order frozen across edits so the RHP + // carousel counter doesn't jump, mirroring how the report carousel reads from a frozen search + // snapshot instead of a live re-sort. Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) + // can write a different ordering to the same Onyx key; keying on membership also avoids overwriting + // theirs on a referential change. + const transactionIDsMembershipKey = useMemo(() => [...visualOrderTransactionIDs].sort().join(','), [visualOrderTransactionIDs]); // Subscribe so the effect closure captures the latest active list. Used to detect when this report's // transactions are a strict subset of a broader carousel (e.g. the user landed here from the flat @@ -533,8 +536,8 @@ function MoneyRequestReportTransactionList({ return () => { clearActiveTransactionIDs(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere - }, [visualOrderTransactionIDsKey]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- transactionIDsMembershipKey is a membership proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere + }, [transactionIDsMembershipKey]); const groupSelectionState = useMemo(() => { const state = new Map(); From 9b6e3f4db0407cf54ba17d172268b878b611ddd2 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:57:34 -0500 Subject: [PATCH 07/16] fix: carousel issue --- ...neyRequestReportTransactionsNavigation.tsx | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index ec3f0dc80028..5fad4439bb7f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -6,10 +6,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import PrevNextButtons from '@components/PrevNextButtons'; import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setOptimisticTransactionThread} from '@libs/actions/Report'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -19,6 +20,7 @@ import navigationRef from '@navigation/navigationRef'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type * as OnyxTypes from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -50,6 +52,13 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const [snapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`); const {markReportIDAsExpense} = useWideRHPActions(); + // Values required to create a transaction thread on the fly when paging onto a multi-transaction + // (batched) parent report that has no existing thread yet (see onNext/onPrevious fallbacks). + const {accountID, email} = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); @@ -182,15 +191,26 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const nextThreadReportID = nextParentReportAction?.childReportID; const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, backTo}; - // No existing transaction thread for this IOU action. Do NOT create one optimistically here: - // the carousel uses Navigation.setParams (mutating the modal's params + remounting via key), - // which races with the in-flight OpenReport call. The server doesn't recognize the synthetic - // optimistic reportID, the local report is wiped, and ReportNavigateAwayHandler bounces the - // user to ROUTES.REPORT_WITH_ID (Inbox/parent). Instead, fall back to navigating to the next - // transaction's parent report. We pass anchorTransactionID so MoneyReportHeader can keep the - // transaction carousel anchored on the user's intended next transaction. + // No existing transaction thread for this IOU action. We reach here only after the + // one-transaction-report branch above, so the parent is a MULTI-transaction (batched) report. + // Navigating to that parent reportID would render the whole report (several expenses) instead of a + // single expense. Create the transaction thread (the same way Search/index.tsx#onSelectRow does on + // first open) so we land on a single-expense view, then navigate to the new thread report. + // createTransactionThreadReport issues a real OpenReport with a server-recognized generated reportID, + // so it doesn't hit the optimistic-reportID race that setOptimisticTransactionThread + setParams does. if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const targetReportID = nextTransaction.reportID; + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: nextTransactionParentReport, + iouReportAction: nextParentReportAction, + transaction: nextTransaction, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? nextTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo})); return; @@ -227,9 +247,21 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const prevThreadReportID = prevParentReportAction?.childReportID; const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, backTo}; - // See onNext for the rationale behind this fallback (and the unreported skip). + // See onNext for the rationale: the parent here is a MULTI-transaction (batched) report, so create the + // transaction thread to land on a single-expense view instead of navigating to the whole parent report. if (!prevThreadReportID && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const targetReportID = prevTransaction.reportID; + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: prevTransactionParentReport, + iouReportAction: prevParentReportAction, + transaction: prevTransaction, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? prevTransaction.reportID; markReportIDAsExpense(targetReportID); requestAnimationFrame(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo})); return; From 901e83ff06a3917c43ae9d9e5146df147d836c3e Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:09:48 -0500 Subject: [PATCH 08/16] refactor: streamline parent report actions collection in MoneyRequestReportTransactionsNavigation component --- ...MoneyRequestReportTransactionsNavigation.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 35f847214243..df45dfd3c6ce 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -31,8 +31,7 @@ type MoneyRequestReportRHPNavigationButtonsProps = { shouldDisplayNarrowVersion?: boolean; }; -const parentReportActionIDsSelector = (reportActions: OnyxEntry) => { - const parentActions = new Map(); +const collectParentReportActions = (reportActions: OnyxEntry, parentActions: Map) => { for (const action of Object.values(reportActions ?? {})) { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; if (!transactionID) { @@ -40,7 +39,6 @@ const parentReportActionIDsSelector = (reportActions: OnyxEntry) => { - let reportActions = {}; + // Build the transactionID -> IOU action map in a single pass. We deliberately avoid merging the + // report actions into one intermediate object (repeated spreads are O(n²) and, with a snapshot, + // would copy every report's actions), since this selector re-runs on any report-action change. + const parentActions = new Map(); // Reported transactions keep their IOU action on their own report (reportActions_). for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; - reportActions = {...reportActions, ...(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined))}; + collectParentReportActions(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined), parentActions); } // Unreported transactions (reportID '0') keep their IOU action on a different report (e.g. a self-DM), - // so it isn't under reportActions_0. Merge every report's actions from the search snapshot so the action + // so it isn't under reportActions_0. Scan every report's actions from the search snapshot so the action // (and its childReportID thread) can still be located by IOUTransactionID. if (snapshot?.data) { for (const [key, reportActionsForReport] of Object.entries(snapshot.data)) { if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { - reportActions = {...reportActions, ...(reportActionsForReport as OnyxTypes.ReportActions)}; + collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, parentActions); } } } - return parentReportActionIDsSelector(reportActions); + return parentActions; }, [currentTransaction, nextTransaction, prevTransaction, snapshot], ); From 9910c5aac2ff9793b54ebec106c3140e893a1e30 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:39:55 -0500 Subject: [PATCH 09/16] feat: add tests --- ...neyRequestReportTransactionsNavigation.tsx | 15 +- .../MoneyReportHeaderMoreContentTest.tsx | 36 +- ...questReportTransactionsNavigation.test.tsx | 369 ++++++++++++++++++ 3 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index df45dfd3c6ce..6537b5ba49d8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -23,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type * as OnyxTypes from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; type MoneyRequestReportRHPNavigationButtonsProps = { @@ -31,13 +32,14 @@ type MoneyRequestReportRHPNavigationButtonsProps = { shouldDisplayNarrowVersion?: boolean; }; -const collectParentReportActions = (reportActions: OnyxEntry, parentActions: Map) => { +const collectParentReportActions = (reportActions: OnyxEntry, parentActions: Record) => { for (const action of Object.values(reportActions ?? {})) { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; if (!transactionID) { continue; } - parentActions.set(transactionID, action); + // eslint-disable-next-line no-param-reassign + parentActions[transactionID] = action; } }; @@ -96,7 +98,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // Build the transactionID -> IOU action map in a single pass. We deliberately avoid merging the // report actions into one intermediate object (repeated spreads are O(n²) and, with a snapshot, // would copy every report's actions), since this selector re-runs on any report-action change. - const parentActions = new Map(); + // We return a plain object (not a Map) because useOnyx's deepEqual is very slow for Maps. + const parentActions: Record = {}; // Reported transactions keep their IOU action on their own report (reportActions_). for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; @@ -117,7 +120,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR [currentTransaction, nextTransaction, prevTransaction, snapshot], ); - const [parentReportActions = new Map()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [parentReportActions = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); @@ -127,8 +130,8 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR } return { - prevParentReportAction: prevTransactionID ? parentReportActions.get(prevTransactionID) : undefined, - nextParentReportAction: nextTransactionID ? parentReportActions.get(nextTransactionID) : undefined, + prevParentReportAction: prevTransactionID ? parentReportActions[prevTransactionID] : undefined, + nextParentReportAction: nextTransactionID ? parentReportActions[nextTransactionID] : undefined, }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx index aa34e16e0d98..8b0e658c2156 100644 --- a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx +++ b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx @@ -89,26 +89,54 @@ describe('MoneyReportHeaderMoreContent', () => { it('renders the next step bar for a Submit workspace', () => { mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - render(); + render( + , + ); expect(mockedNextStepBar).toHaveBeenCalled(); }); it('renders the next step bar for a paid (team) workspace', () => { mockPolicyType(CONST.POLICY.TYPE.TEAM); - render(); + render( + , + ); expect(mockedNextStepBar).toHaveBeenCalled(); }); it('does not render the next step bar for a personal workspace', () => { mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - render(); + render( + , + ); expect(mockedNextStepBar).not.toHaveBeenCalled(); }); it('does not render the next step bar when a status bar is shown', () => { mockPolicyType(CONST.POLICY.TYPE.SUBMIT); mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); - render(); + render( + , + ); expect(mockedNextStepBar).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx new file mode 100644 index 000000000000..c104c6ffb4ef --- /dev/null +++ b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx @@ -0,0 +1,369 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; +import {createTransactionThreadReport} from '@libs/actions/Report'; +import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * These tests verify the navigation resolution of MoneyRequestReportTransactionsNavigation: + * given a transaction list (and optionally a search snapshot), pressing prev/next should resolve + * and navigate to the correct target reportID for each direction. The heavy hooks are mocked so + * the real selectors and the onNext/onPrevious branching logic are exercised in isolation. + */ + +type MockOnyxState = { + transactionIDsList: string[] | undefined; + snapshotHash: string | undefined; + snapshot: {data: Record} | undefined; + siblingDescriptors: Record | undefined; + transactionsCollection: Record; + reportActionsCollection: Record; + reportsCollection: Record; +}; + +const mockState: MockOnyxState = { + transactionIDsList: undefined, + snapshotHash: undefined, + snapshot: undefined, + siblingDescriptors: undefined, + transactionsCollection: {}, + reportActionsCollection: {}, + reportsCollection: {}, +}; + +const mockUseOnyx = jest.fn(); +const mockMarkReportIDAsExpense = jest.fn(); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: (...args: unknown[]) => mockUseOnyx(...args), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 1, email: 'me@example.com'}), +})); + +jest.mock('@components/WideRHPContextProvider', () => ({ + useWideRHPActions: () => ({markReportIDAsExpense: mockMarkReportIDAsExpense}), +})); + +type ReactActual = {createElement: typeof React.createElement; Fragment: typeof React.Fragment}; +type ReactNativeActual = { + Pressable: React.ComponentType<{testID?: string; disabled?: boolean; onPress?: () => void}>; + Text: React.ComponentType<{children?: React.ReactNode}>; +}; + +jest.mock('@components/Text', () => { + const {Text} = jest.requireActual('react-native'); + return {__esModule: true, default: Text}; +}); + +jest.mock('@components/PrevNextButtons', () => { + const ReactLib = jest.requireActual('react'); + const {Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: {onNext: () => void; onPrevious: () => void; isNextButtonDisabled?: boolean; isPrevButtonDisabled?: boolean}) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement(Pressable, {testID: 'prev-button', disabled: props.isPrevButtonDisabled, onPress: () => props.onPrevious()}), + ReactLib.createElement(Pressable, {testID: 'next-button', disabled: props.isNextButtonDisabled, onPress: () => props.onNext()}), + ), + }; +}); + +jest.mock('@navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn(), + getActiveRoute: jest.fn(() => 'active-route'), + }, +})); + +jest.mock('@navigation/navigationRef', () => ({ + __esModule: true, + default: { + getRootState: jest.fn(() => ({index: 0, routes: [{key: 'k', name: 'testRoute'}]})), + getCurrentRoute: jest.fn(() => undefined), + }, +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(() => undefined), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + clearActiveTransactionIDs: jest.fn(), +})); + +jest.mock('@libs/TransactionThreadNavigationUtils', () => ({ + getReportIDToOpenForExpense: jest.fn(() => 'resolved-descriptor-report'), +})); + +const makeIOUAction = (transactionID: string, {childReportID, reportID}: {childReportID?: string; reportID: string}) => ({ + reportActionID: `action_${transactionID}`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {IOUTransactionID: transactionID, type: 'create'}, + childReportID, + reportID, +}); + +const CURRENT_ID = 'tCur'; +const PREV_ID = 'tPrev'; +const NEXT_ID = 'tNext'; + +const resetMockState = () => { + mockState.transactionIDsList = [PREV_ID, CURRENT_ID, NEXT_ID]; + mockState.snapshotHash = undefined; + mockState.snapshot = undefined; + mockState.siblingDescriptors = undefined; + mockState.transactionsCollection = {}; + mockState.reportActionsCollection = {}; + mockState.reportsCollection = {}; +}; + +const setupUseOnyx = () => { + mockUseOnyx.mockImplementation((key: string, options?: {selector?: (data: unknown) => unknown}) => { + const selector = options?.selector; + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { + return [mockState.transactionIDsList]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH) { + return [mockState.snapshotHash]; + } + if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${mockState.snapshotHash}`) { + return [mockState.snapshot]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS) { + return [mockState.siblingDescriptors]; + } + if (key === ONYXKEYS.COLLECTION.TRANSACTION) { + return [selector ? selector(mockState.transactionsCollection) : undefined]; + } + if (key === ONYXKEYS.COLLECTION.REPORT_ACTIONS) { + return [selector ? selector(mockState.reportActionsCollection) : undefined]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + return [mockState.reportsCollection[key]]; + } + // NVP_ONBOARDING (selector-based), NVP_INTRO_SELECTED, BETAS and anything else are not relevant to resolution. + return [undefined]; + }); +}; + +const renderNavigation = () => render(); + +// Navigation.setParams is deferred inside requestAnimationFrame. Run it synchronously so the resolved +// navigation happens during the press and can be asserted immediately afterwards. +const press = (testID: string) => { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(0); + return 0; + }; + fireEvent.press(screen.getByTestId(testID)); +}; + +describe('MoneyRequestReportTransactionsNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetMockState(); + setupUseOnyx(); + }); + + describe('one-transaction parent report', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }; + }); + + it('navigates next to the parent reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', reportActionID: undefined})); + expect(mockMarkReportIDAsExpense).toHaveBeenCalledWith('rNext'); + }); + + it('navigates previous to the parent reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', reportActionID: undefined})); + expect(mockMarkReportIDAsExpense).toHaveBeenCalledWith('rPrev'); + }); + }); + + describe('resolves siblings and parents from the search snapshot', () => { + beforeEach(() => { + // Live collections are intentionally empty; everything is only in the snapshot. + mockState.snapshotHash = 'hash1'; + mockState.snapshot = { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }, + }; + }); + + it('navigates next using snapshot-only data', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext'})); + }); + + it('navigates previous using snapshot-only data', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev'})); + }); + }); + + describe('multi-transaction parent with an existing thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('navigates next to the existing transaction thread reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext'})); + expect(mockMarkReportIDAsExpense).toHaveBeenCalledWith('threadNext'); + }); + + it('navigates previous to the existing transaction thread reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev'})); + expect(mockMarkReportIDAsExpense).toHaveBeenCalledWith('threadPrev'); + }); + }); + + describe('multi-transaction parent without a thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('creates a thread and navigates next, anchoring on the target transaction', () => { + renderNavigation(); + + press('next-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + // createTransactionThreadReport is mocked to return undefined, so the target falls back to the transaction's own reportID. + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); + }); + + it('creates a thread and navigates previous, anchoring on the target transaction', () => { + renderNavigation(); + + press('prev-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); + }); + }); + + describe('snapshot-backed sibling descriptors', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + // No parent report present -> not a one-transaction report, so resolution uses the descriptor. + mockState.siblingDescriptors = { + [PREV_ID]: {reportID: 'rPrev'}, + [NEXT_ID]: {reportID: 'rNext'}, + }; + }); + + it('navigates next to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descNext'); + renderNavigation(); + + press('next-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descNext'})); + }); + + it('navigates previous to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descPrev'); + renderNavigation(); + + press('prev-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descPrev'})); + }); + }); + + it('does not render navigation when there are fewer than two transactions', () => { + mockState.transactionIDsList = [CURRENT_ID]; + + renderNavigation(); + + expect(screen.queryByTestId('next-button')).toBeNull(); + }); +}); From 4bc56ec51d1f62ddb12b42b26704c930cdac5b05 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:10:31 -0500 Subject: [PATCH 10/16] fix: linter --- src/components/MoneyReportHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 05c65ef91d5f..7641d4e1d803 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -88,7 +88,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt // passes `anchorTransactionID`), use that transaction as the carousel anchor so the user can keep // paging the broader list. Falls back to the first of this report's transactions found in the active // list, so the carousel still renders even without an explicit hint. - const anchorTransactionIDFromRoute = (route.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; + const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; const multiTxAnchorTransactionID = useMemo(() => { if (singleTransactionID) { return undefined; From c316521c0ee15fbe6a8df295a7d2b1ca9cfe4abf Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:37:34 -0500 Subject: [PATCH 11/16] fix: bug --- src/components/MoneyReportHeader.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 7641d4e1d803..bb1792c15068 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -85,9 +85,17 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt // For multi-tx parents we don't have a single transaction to anchor on, but if the parent was // navigated to from a broader carousel (the no-thread fallback in MoneyRequestReportTransactionsNavigation - // passes `anchorTransactionID`), use that transaction as the carousel anchor so the user can keep - // paging the broader list. Falls back to the first of this report's transactions found in the active - // list, so the carousel still renders even without an explicit hint. + // passes `anchorTransactionID`), use that transaction as the carousel anchor so the user can keep paging + // the broader list. + // + // We intentionally do NOT fall back to "the first of this report's transactions in the active list". The + // transaction carousel is a single-expense (SEARCH_REPORT) concept: its ◄/► handlers navigate the current + // screen via Navigation.setParams. When the user opens a full multi-transaction report from the header, the + // report renders on SEARCH_MONEY_REQUEST_REPORT; a generic fallback there would surface the carousel and + // paging would setParams to a sibling that renders empty ("No expenses yet"). Viewing a full report is a + // report-level context, so we leave carousel navigation to MoneyRequestReportNavigation. This fallback is + // also unnecessary now that the carousel's no-thread path opens a single-expense thread (never a full + // report body), so we only ever reach a multi-transaction report body by opening it explicitly. const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; const multiTxAnchorTransactionID = useMemo(() => { if (singleTransactionID) { @@ -99,11 +107,8 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt if (anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute)) { return anchorTransactionIDFromRoute; } - if (!activeTransactionIDs) { - return undefined; - } - return transactions.find((t) => activeTransactionIDs.includes(t.transactionID))?.transactionID; - }, [singleTransactionID, anchorTransactionIDFromRoute, transactions, activeTransactionIDs]); + return undefined; + }, [singleTransactionID, anchorTransactionIDFromRoute, activeTransactionIDs]); const carouselAnchorTransactionID = singleTransactionID ?? multiTxAnchorTransactionID; const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); From badac3b4e62a3fea20a52d3cf07495cb17268eb6 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:31:33 -0500 Subject: [PATCH 12/16] fix: fmt --- .../MoneyRequestReportTransactionsNavigation.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx index c104c6ffb4ef..1c6830696f53 100644 --- a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx +++ b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx @@ -1,12 +1,17 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import React from 'react'; + import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; + import {createTransactionThreadReport} from '@libs/actions/Report'; import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; + import Navigation from '@navigation/Navigation'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import React from 'react'; + /** * These tests verify the navigation resolution of MoneyRequestReportTransactionsNavigation: * given a transaction list (and optionally a search snapshot), pressing prev/next should resolve From 243a73584bd24934c12c4d5e1f69aa167b27dfc3 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:48:41 -0500 Subject: [PATCH 13/16] fix: lint --- src/components/Search/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 54ed1a132128..d18222eb8a4d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -517,8 +517,8 @@ function Search({ // When opening an expense from the Spend page (flat transaction list), populate the carousel // with all sibling transactions so prev/next navigation works in the RHP transaction view. if (isTransactionItem) { - const siblingTransactionIDs = (filteredData as TransactionListItemType[]) - .filter((t) => !!t && 'transactionID' in t && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + const siblingTransactionIDs = (filteredData as SearchListItem[]) + .filter((t): t is TransactionListItemType => !!t && isTransactionListItemType(t) && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((t) => t.transactionID); if (siblingTransactionIDs.length > 1) { setActiveTransactionIDs(siblingTransactionIDs, hash); From 1c1cdfbb5d4091c1ff027b19bee3bfcc31d01a65 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:16:58 -0500 Subject: [PATCH 14/16] fix: test --- src/libs/ExportOnyxState/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..8816ac640a0e 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -286,6 +286,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, + ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, From db60b5dde5242aca72e227f40109ab0d6bc9c293 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:23:43 -0500 Subject: [PATCH 15/16] fix: refactor MoneyReportHeader and MoneyRequestReportNavigation components --- src/components/MoneyReportHeader.tsx | 33 ++++--------------- .../MoneyRequestReportNavigation.tsx | 13 ++++++-- .../actions/TransactionThreadNavigation.ts | 3 ++ 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index eb6c18e7fbf3..6b2da1df5356 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -18,7 +18,7 @@ import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -87,32 +87,13 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt // other one-tx parents) or the transaction thread (for multi-tx parents). const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; - // For multi-tx parents we don't have a single transaction to anchor on, but if the parent was - // navigated to from a broader carousel (the no-thread fallback in MoneyRequestReportTransactionsNavigation - // passes `anchorTransactionID`), use that transaction as the carousel anchor so the user can keep paging - // the broader list. - // - // We intentionally do NOT fall back to "the first of this report's transactions in the active list". The - // transaction carousel is a single-expense (SEARCH_REPORT) concept: its ◄/► handlers navigate the current - // screen via Navigation.setParams. When the user opens a full multi-transaction report from the header, the - // report renders on SEARCH_MONEY_REQUEST_REPORT; a generic fallback there would surface the carousel and - // paging would setParams to a sibling that renders empty ("No expenses yet"). Viewing a full report is a - // report-level context, so we leave carousel navigation to MoneyRequestReportNavigation. This fallback is - // also unnecessary now that the carousel's no-thread path opens a single-expense thread (never a full - // report body), so we only ever reach a multi-transaction report body by opening it explicitly. + // For multi-tx parents reached from a broader carousel, the no-thread fallback in + // MoneyRequestReportTransactionsNavigation passes `anchorTransactionID`; anchor on it (when it's part of + // the active carousel list) so the user can keep paging. We deliberately don't fall back to "the first of + // this report's transactions in the active list": viewing a full report is a report-level context handled + // by MoneyRequestReportNavigation, and a generic anchor here would page siblings into an empty report body. const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; - const multiTxAnchorTransactionID = useMemo(() => { - if (singleTransactionID) { - return undefined; - } - // Trust the route hint as long as it's part of the active carousel list. We don't also require it to be in - // this report's `transactions` because when arriving from a search-based carousel the parent report's - // transactions may not be in the live collection yet, which would otherwise hide the carousel entirely. - if (anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute)) { - return anchorTransactionIDFromRoute; - } - return undefined; - }, [singleTransactionID, anchorTransactionIDFromRoute, activeTransactionIDs]); + const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; const carouselAnchorTransactionID = singleTransactionID ?? multiTxAnchorTransactionID; const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 8e3bff3f39af..3611c184ce65 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -22,6 +22,7 @@ import type LastSearchParams from '@src/types/onyx/ReportNavigation'; import type {OnyxEntry} from 'react-native-onyx'; +import {useIsFocused} from '@react-navigation/native'; import React, {startTransition, useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -115,6 +116,7 @@ function MoneyRequestReportNavigationStandalone({onReportsChange}: MoneyRequestR function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, contextReports}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const isFocused = useIsFocused(); // Lightweight subscriptions only: the current search query and its loading flag. These never mount // the heavy useSearchSections subscription set, so the fast context path stays cheap. @@ -153,7 +155,14 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - if (!lastSearchQuery?.queryJSON) { + // Only the focused screen's carousel may reconcile previousLengthOfResults. Multiple report screens + // can be mounted at once (react-navigation keeps stacked screens alive, each with its own header + // carousel) and their lists can differ in length (e.g. a background screen frozen on its cached + // lastValidReports while a newly created report is added to the live list). Since every instance + // subscribes to lastSearchQuery, two unfocused writers that disagree on the length would re-trigger + // each other forever — an infinite Onyx write loop that re-renders the whole app until React throws + // "Maximum update depth exceeded" (seen when creating a report on top of an open report view). + if (!isFocused || !lastSearchQuery?.queryJSON) { return; } @@ -183,7 +192,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi ...lastSearchQuery, previousLengthOfResults: effectiveAllReports.length, }); - }, [currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + }, [isFocused, currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); const goToReportId = (reportId?: string) => { if (!reportId) { diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 48f19b96d38c..ab0f1131fbb4 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -60,6 +60,9 @@ function areDescriptorMapsEqual(a: Record) { const nextSnapshotHash = snapshotHash ?? null; const nextDescriptors = siblingDescriptorsByTransactionID ?? null; + // The comparison is positional on purpose: the array order defines the carousel's prev/next order, + // so the same IDs in a different order (e.g. after the source list is re-sorted) are a real change + // and must be written to Onyx. const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { return Promise.resolve(); From 55149dc5a2137ea166e69a9ae6cdfaaaf44f9904 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:26:28 -0500 Subject: [PATCH 16/16] fix: remove code comments --- src/components/MoneyReportHeader.tsx | 10 ------- .../MoneyRequestReportNavigation.tsx | 7 ----- .../MoneyRequestReportTransactionList.tsx | 26 +++---------------- 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 6b2da1df5356..7147a37ac0d0 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -80,18 +80,8 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const [activeTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - // When the user opens a one-transaction parent report from the flat Spend > Expenses list, the report-level - // carousel (search-based) is inactive (search type is EXPENSE, not EXPENSE_REPORT). Fall back to the - // transaction carousel, anchored on the parent's single transaction, so navigating ◄/► pages through the - // expenses the user was browsing. The carousel itself handles routing to either the parent report (for - // other one-tx parents) or the transaction thread (for multi-tx parents). const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; - // For multi-tx parents reached from a broader carousel, the no-thread fallback in - // MoneyRequestReportTransactionsNavigation passes `anchorTransactionID`; anchor on it (when it's part of - // the active carousel list) so the user can keep paging. We deliberately don't fall back to "the first of - // this report's transactions in the active list": viewing a full report is a report-level context handled - // by MoneyRequestReportNavigation, and a generic anchor here would page siblings into an empty report body. const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; const carouselAnchorTransactionID = singleTransactionID ?? multiTxAnchorTransactionID; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 3611c184ce65..4299d1b9890d 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -155,13 +155,6 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - // Only the focused screen's carousel may reconcile previousLengthOfResults. Multiple report screens - // can be mounted at once (react-navigation keeps stacked screens alive, each with its own header - // carousel) and their lists can differ in length (e.g. a background screen frozen on its cached - // lastValidReports while a newly created report is added to the live list). Since every instance - // subscribes to lastSearchQuery, two unfocused writers that disagree on the length would re-trigger - // each other forever — an infinite Onyx write loop that re-renders the whole app until React throws - // "Maximum update depth exceeded" (seen when creating a report on top of an open report view). if (!isFocused || !lastSearchQuery?.queryJSON) { return; } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index f7e3047f4d65..a06477623d26 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -485,20 +485,8 @@ function MoneyRequestReportTransactionList({ return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // Membership proxy for visualOrderTransactionIDs used as the effect dependency below. - // We key on the *sorted set* of IDs (not their visual order) so the effect only re-fires when a - // transaction is added or removed - not when an edit (e.g. changing an expense's date or category) - // re-sorts the parent list. This keeps the carousel's stored order frozen across edits so the RHP - // carousel counter doesn't jump, mirroring how the report carousel reads from a frozen search - // snapshot instead of a live re-sort. Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) - // can write a different ordering to the same Onyx key; keying on membership also avoids overwriting - // theirs on a referential change. const transactionIDsMembershipKey = useMemo(() => [...visualOrderTransactionIDs].sort().join(','), [visualOrderTransactionIDs]); - // Subscribe so the effect closure captures the latest active list. Used to detect when this report's - // transactions are a strict subset of a broader carousel (e.g. the user landed here from the flat - // Spend > Expenses carousel via the no-thread fallback in MoneyRequestReportTransactionsNavigation) - // and skip overwriting in that case. const [latestActiveTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); useEffect(() => { @@ -506,10 +494,7 @@ function MoneyRequestReportTransactionList({ if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } - // If we arrived from a broader (search) carousel anchored on a specific transaction — the no-thread fallback - // in MoneyRequestReportTransactionsNavigation passes `anchorTransactionID` — keep that carousel's list (and its - // snapshot context) intact. The superset check below only covers reports fully contained in the active list; - // this also covers reports whose transactions are only partially present in the search results. + const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; if (anchorTransactionID && latestActiveTransactionIDs?.includes(anchorTransactionID)) { return; @@ -521,16 +506,11 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { return; } - // Don't overwrite active transaction IDs for one-transaction parent reports. When such a report is opened - // from the flat Spend > Expenses list, the search-list IDs were already set by Search/index.tsx onSelectRow - // and they back the carousel; overwriting with just this report's single transaction would collapse it. + if (visualOrderTransactionIDs.length < 2) { return; } - // Same reasoning for multi-tx parents: if the existing carousel already contains every transaction - // from this report (i.e. is a strict superset), we arrived here from a broader carousel — likely - // via the no-thread fallback in MoneyRequestReportTransactionsNavigation. Overwriting would shrink - // the active list to just this report and the user would lose their place in the broader carousel. + if ( latestActiveTransactionIDs && latestActiveTransactionIDs.length > visualOrderTransactionIDs.length &&