Redirect the expense of a single-expense report to the report view - #99811
Conversation
…ort view Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
Android: Native — Result: pass — all 6 test steps verified on standalone Android NewDot. Created a €25.00 "Test Merchant" expense on "Melvin Bot's Workspace" and submitted it into "Expense Report 2026-08-29" (report
Evidence (7)Single-expense report view after submission
Tapping expense from chat lands on Expense Report view
Transaction thread root URL redirects to Expense Report view
Report shows expense details plus a working added comment
First expense in a 2-expense report opens its own transaction thread
Second expense in a 2-expense report opens its own transaction thread
Message-specific thread link opens thread and highlights message, no redirect
Evidence (1)Recordings- [▶ Session recording of the verification run](https://melvinbot-assets.exops.io/session-33230351756-1-e7d63f40-0919-4bed-a567-d2c798ba13b6.mp4) |
|
@MelvinBot pull main and fix Oxfmt check |
…ExpenseReportThread Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR. Next steps: A human should review the recent commits and resolve the conflicting CI errors manually. |
|
Done — merged What was wrong: two files weren't oxfmt-formatted. Running Commits: AI tests run locally
The full The only other failing check is |
|
@codex review |
|
@MelvinBot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4101fdf2aa
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const backTo = route.params?.backTo; | ||
| const reportRoute = | ||
| route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT | ||
| ? ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID, backTo}) | ||
| : ROUTES.REPORT_WITH_ID.getRoute(parentReportID, undefined, undefined, backTo); |
There was a problem hiding this comment.
Avoid replacing the thread with a duplicate parent route
When the thread is opened from its parent report, getReportRouteForCurrentContext() sets backTo to that parent and pushes the child thread. Reusing that value here and force-replacing the child produces a stack such as parent -> parent?backTo=parent, so the first Back action appears to do nothing and the replacement route has a self-referential fallback. Detect when backTo already targets parentReportID and return to the existing parent instead of replacing the child with another copy.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2dd1e9e. When backTo already targets parentReportID, the handler now calls Navigation.goBack(backTo) to pop onto the parent that is already in the stack, instead of force-replacing the thread with a second copy of it. Covered by three new tests: inbox, Search RHP, and a backTo that points at a different report (which still takes the replace path).
ReviewThe approach is sound and it does solve #99356 — a route-level backstop is the right shape for "deep link / push notification / stale history" entry points that no call site controls. All checks are green except 1. This is the third definition of "one-transaction thread" — reuse the existing one
The new copy also diverges: it adds a 2. The
|
|
@MelvinBot address #99811 (comment) and #99811 (comment). |
…cate parent - Add useIsOneTransactionThread, the reactive counterpart of ReportUtils.isOneTransactionThread, and use it in the redirect handler instead of a third hand-rolled copy of the condition. - Go back to the parent report when backTo already points at it, rather than force-replacing the thread with parent?backTo=parent. - Add a real-Onyx test suite covering the derivation the mocked suite mocks away, plus cases for the new backTo behaviour and the redirect-once guard. Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
Addressed the Codex comment and points 1 and 5 of the review in Codex: duplicate parent routeFixed. When 1. Third definition of "one-transaction thread"Fixed. Added The 5. Tests mocked away the logicFixed. Added Skipped
Local checks
|
|
@codex review |
|
@MelvinBot review |
|
@MelvinBot Findings below, roughly in order of how much they matter. Only address valid bugs. Ignore nits/edge cases. 1. The redirect drops
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2dd1e9e2ff
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| // The active route is captured with a leading slash and may carry query params of its own. | ||
| const backToPath = backTo.replace(/^\//, '').replace(/\?.*$/, ''); | ||
| return backToPath === ROUTES.REPORT_WITH_ID.getRoute(parentReportID) || backToPath === ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}); |
There was a problem hiding this comment.
Recognize anchored parent report routes
When the expense is opened from a parent URL containing a reportActionID (for example r/<parent>/<action> or search/view/<parent>/<action>), getReportRouteForCurrentContext() stores that full URL in backTo, but this equality check only accepts the unanchored route. The handler therefore force-replaces the thread with another parent screen instead of popping to the parent already in the stack, so Back returns to a second view of the same report. Fresh evidence beyond the prior comment is that both route definitions explicitly support the optional :reportActionID? suffix (src/ROUTES.ts), and ChatTransactionPreview opens the child through the context helper while such an anchored parent can be active.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Skipping this one as an edge case, on aimane-chnaif's instruction.
The mechanism is real — both route definitions do take the optional :reportActionID? suffix, and getReportRouteForCurrentContext stores Navigation.getActiveRoute() verbatim. But it only bites when the parent report was itself opened at a message anchor and the expense is then opened from there, which is narrow enough to leave alone for now.
I did widen isBackToParentReport in 1868bf9 for the unanchored cases it was genuinely missing — search/r/<id> and e/<id> are both the parent expense report and both used to fall through to the stacking branch.
Code reviewThe approach is sound — a route-level backstop is the right shape for this, the 1.
|
…ent action read Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
Fixed #1 and #2 in Fixed1. Dropped I hoisted it to a 2. Gating the parent action read — taking it as a perf fix. Not a bug, but the reasoning holds and the change is free: Skipped4. Not a simplification — dropping the wrapper breaks the 3. Your reading checks out — 5, 6. Both self-described as not live bugs ( Verification
I could not smoke-test the redirect in the browser — the prestarted web session never got past the account-creation "Join" screen and the page went blank (0 DOM nodes, no network traffic) for the whole run, with the dev server itself responding 200. So the perf gate in #2 is covered by the unit suites only, including the real-Onyx one, and not by a live flow. |
|
@MelvinBot address only valid items in #99811 (comment), #99811 (comment). Ignore edge cases |
|
@aimane-chnaif Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb ChromeiOS: HybridAppios.moviOS: mWeb SafariMacOS: Chrome / Safariweb.mov |
|
We did not find an internal engineer to review this PR, trying to assign a random engineer to #99356 as well as to this PR... Please reach out for help on Slack if no one gets assigned! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 53750646c9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| // Replacing rather than pushing keeps the thread we are leaving out of the history, so going back doesn't | ||
| // land on it again. | ||
| Navigation.navigate(getExpenseReportRoute({routeName: route.name, reportID: parentReportID, referrer, backTo: resolvedBackTo}), {forceReplace: true}); |
There was a problem hiding this comment.
Pop to the existing parent instead of replacing the thread
When the thread was opened from its own parent report, that parent route is already immediately beneath it—the ChatTransactionPreview flow navigates through getReportRouteForCurrentContext(), which records the active parent in backTo and pushes the child. Stripping the self-referencing backTo and then force-replacing the child still leaves a parent -> parent stack, so the first Back action appears to do nothing. Fresh evidence beyond the prior comment is that the current implementation no longer calls Navigation.goBack() when isBackToParentReport is true; it unconditionally executes this replacement.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
- In a workspace chat, click the report preview card for a report holding two expenses. It opens in the wide right-hand pane.
- Click the first expense. It opens on its own, still in the pane.
- From a second session, open the second expense → More → Move to report, and move it elsewhere.
- The pane switches by itself to the expense report — the feature working.
- Need to press Back twice to actually leave.
This also happens on production so out of scope
|
PR body set to your content and the PR is now ready for review. 🤖 Android test steps verification results: ✅ pass Created a 2-expense workspace report, confirmed E1 opened its own transaction thread while the report had 2 expenses, then after moving E2 elsewhere, navigating to E1's expense view redirected to the parent report (header shows report name) instead of the transaction thread, with no JS console errors.
view run · no recording available |
|
@MelvinBot pull main |
…ExpenseReportThread Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
@aimane-chnaif Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
Merged Re-ran the checks on the merged tree, all green:
The PR still shows view run · no recording available |
blimpich
left a comment
There was a problem hiding this comment.
Looks good but way too many verbose comments. Please keep those to a minimum
|
@MelvinBot address #99811 (review) |
Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
|
Trimmed the comments in What I kept is the handful of "why" notes that aren't recoverable from the code: the transaction-count gate existing because a paginating report looks single-expense to the action-derived check, the Checks
No typecheck run — every changed line is a comment, verified with |
|
🚧 blimpich has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/blimpich in version: 9.4.78-0 🚀
|
|
No help site changes are required, so I did not create a docs PR. This PR changes which route the app lands on — the transaction thread of a single-expense report is replaced with the report itself. Nothing about it changes a feature name, a tab, a setting, a button label, or the steps a member follows. No article under What I checkedSearched The one flow worth verifying was I ran this on the PR branch on web rather than assume it. Both documented flows still work unchanged: clicking the expense row opens the report, and Merchant, Category, Amount, and Report are all present and editable there.
Clicking Merchant opens its edit page as documented:
No console errors during the run. Since no docs PR was needed, there is no linked help site PR to review. If you'd rather I add a note to one of those two articles anyway, say the word and I'll open the draft PR. |


















Explanation of Change
This PR adds
OneTransactionThreadRedirectHandler, a render-nothing handler mounted inReportScreenalongside the existing route handlers. When a route lands on the transaction thread of a single-expense report, it replaces the route with the report itself.It also adds
useIsOneTransactionThreadhook, a reactive counterpart ofReportUtils.isOneTransactionThread, which reads module globals and so can't drive rendering. It's the same definitionHeaderViewandSidebarUtilsuse, so the redirect and the thread's own views agree.It does not redirect when:
transactionCountisn't 1 — the action-based check can briefly see a multi-expense report that's still paginating in as a single-expense onereportActionID— that deep link points at a message, and replacing the route would drop its anchorTRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) — the prev/next arrows only exist in a thread's header, so Home "Recently added", "Review N flagged expenses" and the duplicate review list would dead-end mid-reviewWhere it redirects to — the route shape the app itself would have used from where we are:
r/<reportID>, carryingreferrersearch/view/<reportID>e/<reportID>Back navigation. It replaces (
forceReplace) rather than pushes, so Back doesn't return to the thread, and it reuses the route's ownbackTo.One case needs more: a thread opened from its own report carries that report as
backTo, which would point the report at itself. The handler resolvesbackTothroughgetStateFromPath+findFocusedRouteand compares the resultingreportID; when it's the parent, it inherits that route's nestedbackToinstead. Resolving through the navigator rather than comparing paths matters because the same report is reachable asr/,e/,search/view/andsearch/r/, with or without a trailingreportActionID.Automated coverage.
tests/navigation/OneTransactionThreadRedirectHandlerTest.tsxmocks the hooks to pin down the decision table above;tests/navigation/OneTransactionThreadRedirectHandlerOnyxTest.tsxfeeds the handler real Onyx data, catching a wrong Onyx key or reportID the mocked suite would pass.Fixed Issues
$ #99356
Tests
Offline tests
Same as Tests
QA Steps
Same as Tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)Avatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.