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 &&