Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
866d0b6
Navigate to clicked expense in multi-expense report preview and alway…
wildan-m May 19, 2026
92f1af9
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 1, 2026
9f55fd6
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 3, 2026
d3d086e
Add unit tests for multi-expense preview transaction navigation
wildan-m Jun 3, 2026
cab5457
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 5, 2026
89b62ed
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jun 6, 2026
5eaa24e
Show the primary action before the View button in the report preview
wildan-m Jun 6, 2026
0ead53d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
e46b769
Open the report below the pressed expense; single-expense reports ope…
wildan-m Jun 9, 2026
e408854
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
8dd5a27
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
73d3719
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
f04f96a
Open the pressed expense over the report in the wide RHP, not via the…
wildan-m Jun 9, 2026
f3b1226
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jun 12, 2026
22a0f66
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 15, 2026
cfff64b
Open the pressed expense over the report's wide RHP after a short delay
wildan-m Jun 15, 2026
16793ef
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 23, 2026
e383b44
Open the pressed expense with a single forward push on narrow layouts
wildan-m Jun 23, 2026
30f3b95
Let the View button hug its content so a long primary label is not tr…
wildan-m Jun 23, 2026
fd46c13
Re-trigger CI (flaky TrackExpenseTest from main merge, unrelated to t…
wildan-m Jun 23, 2026
bfce6ee
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 23, 2026
d5d88d0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 24, 2026
e2de5c0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 25, 2026
2e04534
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 25, 2026
de10193
Keep the report in the stack on narrow and open the pressed expense a…
wildan-m Jun 26, 2026
08dc97d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 26, 2026
bfa7e91
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 2, 2026
37f1625
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 3, 2026
670a3fe
Fix iOS backward animation, offline dead tap, and blank uncached expense
wildan-m Jul 3, 2026
a71c3d3
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jul 3, 2026
84dfff2
Open narrow expense as a single forward slide with the parent report …
wildan-m Jul 4, 2026
5ab3727
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 4, 2026
f55f88a
Skip the parent-report splice on web to fix the mobile-web double-back
wildan-m Jul 6, 2026
5bdf3b0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 7, 2026
d7a2caf
Push the report then the expense on web so hard back and header back …
wildan-m Jul 7, 2026
e4cb936
Open the expense in an RHP over the report on mobile web
wildan-m Jul 7, 2026
e3f4901
Open the mobile-web expense RHP in the same tick as the report (no ca…
wildan-m Jul 7, 2026
9ad3fff
Defer the mobile-web expense RHP by one frame so back stops on the re…
wildan-m Jul 7, 2026
eef7176
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 9, 2026
9ddff97
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 13, 2026
9ca40b5
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 13, 2026
c62bfc3
Drop the inert wide RHP width hint on the mobile-web expense open
wildan-m Jul 13, 2026
3c5ad11
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 14, 2026
68444dd
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 15, 2026
764bbfe
Update ReportPreviewActionButton test for the View-alongside layout
wildan-m Jul 15, 2026
13e582f
Guard the wide-layout delayed expense navigation against dismissal
wildan-m Jul 15, 2026
c90aef2
Defer the mobile-web expense RHP until the report settles (fix iOS Sa…
wildan-m Jul 15, 2026
ebbf494
Open the mobile-web expense directly over the chat (fix iOS Safari fr…
wildan-m Jul 15, 2026
ed5c45d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 16, 2026
950904b
mWeb: keep report alive under the expense RHP so back returns to it
wildan-m Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ function ReportPreviewActionButton() {
const {buttonMaxWidth} = useReportPreviewUIState();
const {openReportFromPreview} = useReportPreviewActions();

const renderButton = () => {
const viewButton = (
<Button
text={translate('common.view')}
onPress={openReportFromPreview}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.VIEW_BUTTON}
/>
);

const renderPrimaryButton = () => {
if (reportPreviewAction === CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT) {
return <SubmitActionButton />;
}
Expand All @@ -45,16 +53,23 @@ function ReportPreviewActionButton() {
return <AddExpenseActionButton />;
}

return (
<Button
text={translate('common.view')}
onPress={openReportFromPreview}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.VIEW_BUTTON}
/>
);
return null;
};

return <View style={[buttonMaxWidth, styles.flex1, {height: variables.h40}]}>{renderButton()}</View>;
const primaryButton = renderPrimaryButton();

if (!primaryButton) {
return <View style={[buttonMaxWidth, styles.flex1, {height: variables.h40}]}>{viewButton}</View>;
}

return (
// The primary (green) button fills the available space while the View button hugs its content, so a long
// primary label (e.g. "Mark as paid") isn't truncated by a 50/50 split.
<View style={[buttonMaxWidth, styles.flex1, styles.flexRow, styles.gap2, {height: variables.h40}]}>
<View style={[styles.flex1]}>{primaryButton}</View>
{viewButton}
</View>
);
}

export default ReportPreviewActionButton;
238 changes: 217 additions & 21 deletions src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import TransactionPreview from '@components/ReportActionItem/TransactionPreview';
import {useWideRHPActions} from '@components/WideRHPContextProvider';

import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import useNewTransactions from '@hooks/useNewTransactions';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -11,8 +13,18 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionViolations from '@hooks/useTransactionViolations';

import {createTransactionThreadReport, openReport, setOptimisticTransactionThread} from '@libs/actions/Report';
import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getIOUActionForReportID, isSplitBillAction as isSplitBillActionReportActionsUtils, isTrackExpenseAction as isTrackExpenseActionReportActionsUtils} from '@libs/ReportActionsUtils';
import getPlatform from '@libs/getPlatform';
import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue';
import {
getIOUActionForReportID,
getOriginalMessage,
isMoneyRequestAction,
isSplitBillAction as isSplitBillActionReportActionsUtils,
isTrackExpenseAction as isTrackExpenseActionReportActionsUtils,
} from '@libs/ReportActionsUtils';
import {isIOUReport} from '@libs/ReportUtils';
import {startSpan} from '@libs/telemetry/activeSpans';

Expand All @@ -30,12 +42,16 @@ import type {ListRenderItem} from '@shopify/flash-list';
import type {LayoutChangeEvent} from 'react-native';

import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';

import type {MoneyRequestReportPreviewProps} from './types';

import MoneyRequestReportPreviewContent from './MoneyRequestReportPreviewContent';

// Delay (ms) before the pressed expense opens on top of the report's wide RHP. Letting the report settle
// into the wide RHP first makes the two panels open as a cascade rather than appearing at once.
const PRESSED_EXPENSE_CASCADE_DELAY = 180;

function MoneyRequestReportPreview({
iouReportID,
iouReport,
Expand All @@ -53,7 +69,11 @@ function MoneyRequestReportPreview({
const StyleUtils = useStyleUtils();
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {markReportRHPWidth} = useWideRHPActions();
const personalDetailsList = usePersonalDetails();
const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined;
const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`);
const invoiceReceiverPersonalDetail = chatReport?.invoiceReceiver && 'accountID' in chatReport.invoiceReceiver ? personalDetailsList?.[chatReport.invoiceReceiver.accountID] : null;
Expand All @@ -63,6 +83,14 @@ function MoneyRequestReportPreview({
// reimbursable derivations so they include optimistically-deleted rows, exactly as before the decomposition.
const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction);
const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
// Tracks how many actions the IOU report has loaded so a deferred expense press can be retried once
// the actions arrive (they may be missing right after a cache clear).
const [iouReportActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, {
selector: (reportActions) => Object.keys(reportActions ?? {}).length,
});
// Holds a pressed transaction whose thread report could not be resolved yet, so the expense can be
// opened once the IOU report's actions have loaded instead of falling back to the parent report.
const pendingExpenseTransactionRef = useRef<Transaction | null>(null);
const policy = usePolicy(policyID);
const lastTransaction = transactions?.at(0);
const lastTransactionViolations = useTransactionViolations(lastTransaction?.transactionID);
Expand Down Expand Up @@ -144,27 +172,195 @@ function MoneyRequestReportPreview({

const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle];

const renderItem: ListRenderItem<Transaction> = ({item}) => (
<TransactionPreview
chatReport={chatReport}
action={getIOUActionForReportID(item.reportID, item.transactionID)}
contextAction={action}
reportID={item.reportID}
isBillSplit={isSplitBillAction}
isTrackExpense={isTrackExpenseAction}
isWhisper={isWhisper}
isHovered={isHovered}
iouReportID={iouReportID}
containerStyles={transactionPreviewContainerStyles}
transactionPreviewWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width}
transactionID={item.transactionID}
reportPreviewAction={action}
onPreviewPressed={openReportFromPreview}
shouldShowPayerAndReceiver={shouldShowPayerAndReceiver}
shouldHighlight={!!newTransactionIDs?.has(item.transactionID)}
/>
// Resolve the target transaction thread report. Prefer the IOU action's childReportID, then the
// transaction's own thread id, and finally create the thread inline so the press never lands on a dead route.
const resolveChildReportID = useCallback(
(transaction: Transaction) => {
const transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID);
let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID;
if (childReportID) {
// The thread already exists, but it may not be cached (offline, or after a cache clear). Seed its
// optimistic report shell + parent linkage so navigating to it renders the expense instead of a
// blank/not-found screen — the same thing the canonical transaction-thread openers do.
setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID);
} else if (transactionIOUAction?.reportActionID) {
const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined;
if (transactionID) {
childReportID = createTransactionThreadReport({
introSelected,
currentUserLogin: currentUserEmail ?? '',
currentUserAccountID,
betas,
iouReport,
iouReportAction: transactionIOUAction,
})?.reportID;
}
}
return childReportID;
},
[betas, currentUserAccountID, currentUserEmail, introSelected, iouReport, policyID],
);

const navigateToExpense = useCallback(
(childReportID: string) => {
startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, {
name: 'MoneyRequestReportPreview.Transaction',
op: CONST.TELEMETRY.SPAN_OPEN_REPORT,
});

if (isSmallScreenWidth) {
const backTo = Navigation.getActiveRoute();
if (!iouReportID) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID, undefined, undefined, backTo));
return;
}

// On mobile web, open the parent report as the top of the split (so it stays interactive and is never
// frozen under the expense), then open the pressed expense in the RHP over it — the same way the report
// view and the wide layout open an expense. Browser/OS back and the header back both close the RHP to
// the fully-loaded report, then to the chat. Pushing the expense as a second split report screen
// instead leaves the parent report frozen beneath it (freezeNonTopScreens), so hard-backing to that
// just-thawed, still-loading report drops the first transaction-row tap.
if (getPlatform() === CONST.PLATFORM.WEB) {
// On mobile web, push the parent report first so it lands as its own browser-history entry (back
// stops on the report, then the chat), then open the pressed expense in the RHP over it one
// microtask later so the two navigations don't batch into a single history push. The report stays
// live under the full-screen RHP because FreezeWrapper no longer freezes a split that has an RHP
// over it, so it's never suspended mid-hydration (which used to wedge React concurrent rendering on
// iOS Safari). Mirrors native's back -> report -> chat.
const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo);
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID));
Navigation.navigate(reportRoute);
setNavigationActionToMicrotaskQueue(() => {
// The user may have left the report during the microtask defer; don't open the expense over
// whatever screen is now active.
if (!Navigation.isActiveRoute(reportRoute)) {
return;
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
});
return;
}

// On native the wide RHP is unavailable and there is a swipe-back gesture, so open the expense with the
// parent report as a real stack entry underneath as a single forward slide (see
// openExpenseOverParentReport for the mechanics).
Navigation.openExpenseOverParentReport(iouReportID, childReportID, backTo);
return;
}

// On wide layouts open the expense report itself in the wide RHP (super wide for multi-expense
// reports) and show the pressed expense on top of it — mirroring how an expense opens from the
// report view — rather than navigating to the report in the Inbox. Back returns to the report,
// and back again to the chat.
if (iouReportID) {
const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: Navigation.getActiveRoute()});
markReportRHPWidth(iouReportID, 'super-wide');
Navigation.navigate(reportRoute);
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID)).then(() => {
markReportRHPWidth(childReportID, 'wide');
// Let the report's wide RHP settle before opening the pressed expense on top, so the two
// panels open as a cascade rather than at once.
setTimeout(() => {
// The user may have closed the report's wide RHP or navigated away during the cascade delay;
// don't reopen the expense over whatever screen is now active.
if (!Navigation.isActiveRoute(reportRoute)) {
return;
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
Comment on lines +264 to +270

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel delayed expense navigation after dismissal

When a user taps an expense on a wide layout and then quickly closes the just-opened report RHP or navigates elsewhere before this 180 ms timer fires, the delayed callback still runs with the stale reportRoute and opens the expense over whatever screen is active. This can unexpectedly reopen an expense after the user backed out; guard that the active route is still the report route (or cancel the timeout when the flow is dismissed) before navigating.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 13e582f4666. The wide-branch cascade now guards the delayed navigate on Navigation.isActiveRoute(reportRoute), so if the report's wide RHP is closed (or the user navigates away) during the 180ms delay, the pressed expense no longer reopens over the active screen. Added a regression test for the dismissal case.

}, PRESSED_EXPENSE_CASCADE_DELAY);
});
return;
}

// Fallback when the parent report is unknown: open the pressed expense alone in the wide RHP.
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID)).then(() => {
markReportRHPWidth(childReportID, 'wide');
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: Navigation.getActiveRoute()}));
});
},
[isSmallScreenWidth, iouReportID, markReportRHPWidth, transactions],
);

const openTransactionFromPreview = useCallback(
(transaction: Transaction) => {
if (contextMenuRef.current?.isContextMenuOpening) {
return;
}

// A report with a single expense opens the report itself, not the lone expense — opening the
// expense directly would skip the report the user expects to land on.
if (transactions.length <= 1) {
openReportFromPreview();
return;
}

const childReportID = resolveChildReportID(transaction);
if (childReportID) {
navigateToExpense(childReportID);
Comment thread
wildan-m marked this conversation as resolved.
return;
}

// The thread could not be resolved. If the IOU report's action for this expense isn't loaded yet
// (e.g. right after a cache clear), fetch the report's actions and open the expense once they arrive
// (see the effect below) instead of falling back to the parent report and losing the pressed expense.
// Skip this while offline: openReport can't fetch, so the deferred press would never fire (dead tap) —
// fall through to opening the cached parent report instead, matching the "View" button.
const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID);
if (!isIOUActionLoaded && iouReportID && !isOffline) {
pendingExpenseTransactionRef.current = transaction;
openReport({reportID: iouReportID, introSelected, betas});
return;
}

openReportFromPreview();
},
[betas, introSelected, iouReportID, isOffline, navigateToExpense, openReportFromPreview, resolveChildReportID, transactions.length],
);

// Completes a deferred expense press once the IOU report's actions have loaded.
useEffect(() => {
const pendingTransaction = pendingExpenseTransactionRef.current;
if (!pendingTransaction) {
return;
}
const childReportID = resolveChildReportID(pendingTransaction);
if (childReportID) {
pendingExpenseTransactionRef.current = null;
navigateToExpense(childReportID);
return;
}
// The actions finished loading but the expense still has no resolvable thread — open the parent report.
if (iouReportActionCount) {
pendingExpenseTransactionRef.current = null;
openReportFromPreview();
}
}, [iouReportActionCount, navigateToExpense, openReportFromPreview, resolveChildReportID]);

const renderItem: ListRenderItem<Transaction> = ({item}) => {
const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID);
return (
<TransactionPreview
chatReport={chatReport}
action={transactionIOUAction}
contextAction={action}
reportID={item.reportID}
isBillSplit={isSplitBillAction}
isTrackExpense={isTrackExpenseAction}
isWhisper={isWhisper}
isHovered={isHovered}
iouReportID={iouReportID}
containerStyles={transactionPreviewContainerStyles}
transactionPreviewWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width}
transactionID={item.transactionID}
reportPreviewAction={action}
onPreviewPressed={() => openTransactionFromPreview(item)}
shouldShowPayerAndReceiver={shouldShowPayerAndReceiver}
shouldHighlight={!!newTransactionIDs?.has(item.transactionID)}
/>
);
};

return (
<MoneyRequestReportPreviewContent
newTransactionIDs={newTransactionIDs}
Expand Down
Loading
Loading