You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-09-02] [$250] iOS - Tapping the back button navigates the user to the Inbox after moving the expense #98106
Version Number: v9.4.51-1 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:https://github.com/Expensify/Expensify/issues/504535 Email or phone of affected tester (no customers): - Issue reported by: Applause Internal Team Bug source: Regression TC Execution Device used: iPhone 13 - iOS 26.5
Action Performed:
Precondition:
Create a new workspace if your account doesn't have any workspace
Open the app
Go to Self DM chat
Submit any track expense
Tap the "Submit it to someone" button of the whisper action
Search for workspaces
Select any workspace and Create expense
After redirected to Workspace chat, tap back button
Expected Result:
User is redirected to Self Chat
Actual Result:
Tapping the Back button navigates the user to the Inbox after moving the expense to the workspace chat.
Root Cause (high confidence): When the track expense is moved to a workspace, the confirmation step "pre-mounts"/reveals the destination workspace report using the REPLACE_FULLSCREEN_UNDER_RHP navigation action. Because a report's outermost fullscreen route is the TAB_NAVIGATOR wrapper, this action takes the tab-switch branch, which rebuilds the Reports split-navigator stack as [Sidebar, WorkspaceReport] and prepends only the sidebar as the back target — dropping the previously-focused Self DM report from the stack. After the RHP dismisses, pressing back on the workspace report pops to the sidebar (Inbox) instead of the Self DM.
What is the root cause of that problem?
The tab-switch branch of handleReplaceFullscreenUnderRHP rebuilds the Reports split navigator and keeps only the sidebar beneath the incoming report, so any report that was open before (the Self DM) is discarded from the back stack.
Evidence & data flow
Flow:
Viewing Self DM → root stack is TAB_NAVIGATOR[REPORTS_SPLIT_NAVIGATOR[Sidebar, SelfDM]].
RHP dismisses → TAB_NAVIGATOR[REPORTS_SPLIT_NAVIGATOR[Sidebar, WorkspaceReport]] → back pops to Sidebar = Inbox. 🐛
Why iOS/narrow-layout only: The bug is on the fast pre-insert/reveal path. The non-pre-inserted path (submitDismissStrategies.ts → dismissModalWithReport) PUSHes the report onto the existing split navigator, producing [Sidebar, SelfDM, WorkspaceReport] — where back correctly returns to Self DM.
What changes do you think we should make in order to solve the problem?
In the tab-switch branch of handleReplaceFullscreenUnderRHP / getTargetTabRoute (GetStateForActionHandlers.ts:207-254), when the focused tab is the Reports split navigator and a different report is already open in the central pane, preserve that previously-focused report beneath the incoming destination report (prepend the existing central-pane report, not just the sidebar). Alternatively, gate the report pre-insert/reveal so that when a different report is topmost in the Reports split navigator, the destination is PUSHed onto the existing split navigator (matching the dismiss/navigate path) instead of doing a full tab rebuild.
What alternative solutions did you explore? (Optional)
Since the correct behavior already exists on the non-pre-insert dismiss path, an alternative is to skip the pre-insert/reveal optimization when the topmost split navigator already holds a different report, falling back to dismissModalWithReport.
Note on verification: This is a code-based analysis. Because the defect is specific to the narrow-layout pre-insert/reveal fast path, the highest-signal confirmation is an on-device iOS repro dumping navigationRef.getRootState() immediately before and after "Create expense" to confirm the REPORT_PRE_INSERT handler fires (vs DISMISS_MODAL).
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/98106) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
VERDICT: POST
ROOT_CAUSE_ONELINER: After moving a track expense to a workspace, dismissModalWithReport calls navigate(workspaceChatRoute) after dismissing the RHP — but since the Inbox tab's stack already contains SCREENS.REPORT (Self DM), React Navigation's navigate action updates that entry's params instead of pushing a new route, so the iOS back stack becomes [INBOX → workspace chat] with Self DM erased from history.
PROPOSAL_TYPE: FIRST
AUTOPOST_SAFE: YES
@ikevin127 I have investigated this issue locally and I found:
Note
I have a better and complete proposal than Melvin with two possible solutions.
I'm also able to reproduce this issue on Android native so it isn't iOS specific:
android-repro.mov
The pre-insert navigation flow is used on narrow layouts and doesn't have separate iOS or Android logic. The pre-insert flow checks getIsNarrowLayout(), not the platform. So the same behavior can occur on Android native, iOS native, and any other narrow layout using this navigation path.
I have an accurate root cause than Melvin. Basically this is the problematic part in the code:
Here, when the existing routes are Inbox > Self DM, the code takes only the first route, which is Inbox, and combines it with the incoming Workspace chat route. Therefore, it creates Inbox > Workspace chat and drops the Self DM before the user presses Back.
I also found these two commits related to this:
commit 1: This commit added getTargetTabRoute().
Basically it was to rebuild a tab’s navigation state when a new screen is opened lazily. It tried to keep a simple back destination, usually Inbox. It is intentionally discarding the Self DM and keeping the Inbox.
commit 2: This commit connected the expense submission to usePreMountDestination. That means while the user is still filling the expense details, the app starts preparing the destination report behind the confirmation modal
After reviewing the commits, I found that getTargetTabRoute intentionally remove the Self DM and keeps the Inbox as main page as a better fallback for enable the lazy navigation.
I have two potential solutions for this:
Ignore the pre-insertion for moving track expenses (but this could prevent getting the advantage of lazy navigation or preparing the next screen earlier before modal closes in expense confirmation). So this solution comes with a tradeoff which also could be seen clearly in the following solution video:
solution.mov
Keep pre-insertion, but preserve the current report when pre-inserting a different report in the Reports tab.
Thanks both. I dug into this against latest main and I'm opening it up for proposals. Reasoning below.
On Melvin's proposal: the root cause is correct and I verified it. getTargetTabRoute in GetStateForActionHandlers.ts takes existingNestedRoutes?.at(0) (line 216) and prepends only that beneath the incoming route, so [Sidebar, SelfDM] gets rebuilt as [Sidebar, WorkspaceReport] and Self DM is dropped before Back is ever pressed.
Where it falls short is the solution section: it offers two opposite directions and picks neither. Preserving the previous report and skipping the pre-insert entirely are not variants of one fix, and the second one is a regression rather than a fix, since getSubmitExpensePreMountDestinationRoute exists specifically to render the destination behind the RHP for an instant reveal.
It also frames the bug as iOS-only, but the gate is getIsNarrowLayout() with no platform branch anywhere on this path, so Android native and narrow mWeb are affected too.
On Uzaifm127's comment: this is the strongest investigation on the thread, and the Android repro plus the commit archaeology are both correct and useful. But it isn't a proposal, it's missing the template sections, and the two solutions listed are the same two Melvin already listed with the same tradeoff called out. The added value is the evidence and the platform correction, not a different solution.
Why I'm opening it up: neither writeup addresses the part that actually makes this tricky. The fix lands inside a path that deliberately strips the focused tab's key in markFocusedTabRouteForRemount to force a remount and avoid the react-native-screens push flash, and the WORKSPACES_LIST branch right above it already seeds a fresh keyless route for the same reason (#90985).
Prepending the previous report means that screen now mounts as part of the reveal. Uzaifm127's own commit research points out the discard was intentional, then moves past it.
A proposal I can accept needs to commit to the preserve approach and answer:
preserve only the focused central-pane route, or the whole existing nested stack ?
scope it to REPORTS_SPLIT_NAVIGATOR, or all tabs? getTargetTabRoute is shared and WORKSPACE_NAVIGATOR already has bespoke handling
does the cancel path still restore correctly? preInsertedOriginalTabRoute feeds handleRemoveFullscreenUnderRHP
does the preserved screen need to mount born-non-top to avoid the reorder flash ?
@Uzaifm127, since you've already done the work and have the videos, please post this as a formal proposal covering the above and I'll review it first.
@MelvinBot Add Help Wanted label on this issue to open it for proposals.
In the OP's video the tracked expense is created in the Self DM, so when the whisper flow starts the report under the money‑request RHP is the Self DM. Selecting a workspace routes the flow to the confirmation step keyed to the destination report (the policy expense chat), e.g. ReportUtils.ts#L12001. Because navigating to that RHP route also aligns the full‑screen route under the overlay (linkTo/index.ts#L222-L238), the Reports split's central report is swapped from the Self DM to the destination workspace chat — verifiable in the confirmation step, where the yusfu's expenses header already renders under the confirmation dialog.
On confirm, navigateAfterExpenseCreate takes the non‑global‑create branch and calls dismissModalAndOpenReportInInboxTab(destinationReportID) (navigateAfterExpenseCreate.ts#L57-L60) → dismissModalWithReport({reportID}) (dismissModalAndOpenReportInInboxTab.ts#L84). Since the topmost report is now the destination (topmostReportID === reportID) and the Reports split is topmost, this hits the early branch that only dismisses the modal, without re‑navigating (Navigation.ts#L919-L925). The RHP peels off to reveal [Sidebar, workspaceChat] — the Self DM is no longer in the stack — so tapping back lands on the Inbox instead of the Self Chat.
What changes do you think we should make in order to solve the problem?
Open the destination workspace chat with backTo set to the originating Self DM, so its header back button returns there regardless of what the swap left in the stack — report headers already resolve back via goBack(backTo), so this is the deterministic mechanism the flow is missing.
Thread the source report through the terminal navigation. The move‑from‑track flow already knows the origin as the draft's linkedTrackedExpenseReportID. Pass it as backTo from navigateAfterExpenseCreate's dismiss branch into dismissModalAndOpenReportInInboxTab → dismissModalWithReport, building it once with ROUTES.REPORT_WITH_ID.getRoute(linkedTrackedExpenseReportID).
Honor it in the collapsed branch. In dismissModalWithReport at Navigation.ts#L919-L925, when the topmost report already equals reportIDand a backTo was supplied, re‑open it with the param instead of a bare dismiss:
This is scoped to the move‑from‑track paths (CATEGORIZE/SHARE/SUBMIT), since only they launch from a report the user expects to return to; the global‑create case passes no backTo and keeps today's "open on the inbox tab" behavior, so no other consumer of dismissModalAndOpenReportInInboxTab changes.
What alternative solutions did you explore? (Optional)
Keep the Self DM under the overlay (suppress the full‑screen swap in linkTo for these RHP steps) so the terminal dismissModalWithReport pushes the destination on top of the Self DM. Fixes back navigation but touches the shared overlay logic in linkTo, which many RHP flows rely on — higher blast radius than attaching a backTo.
Land back on the Self DM and push the workspace chat on top. Rejected: the video's expected result is to land on the workspace chat first and only return to Self Chat on back, so the destination must remain the visible screen.
Please re-state the problem that we are trying to solve in this issue.
On narrow layout (iOS app), after submitting a tracked expense from the self-DM to a workspace via the "Submit it to someone" whisper, the app opens the workspace chat — but pressing the back button lands on the Inbox instead of returning to the self-DM the user came from.
before pre-insert: REPORTS_SPLIT: [Inbox, Report(selfDM)] <- RHP on top
after pre-insert: REPORTS_SPLIT: [Inbox, Report(workspaceChat)] <- selfDM dropped
So when the RHP dismisses and the user presses back on the workspace chat, the stack pops to the Inbox sidebar.
I verified this against the real handler with a unit test (same harness as the existing handleReplaceFullscreenUnderRHP.test.ts): given [Inbox, Report(100)] under the RHP and a pre-insert of Report(200), the handler returns [Inbox, Report(200)] — Report(100) is gone.
What changes do you think we should make in order to solve the problem?
In getTargetTabRoute, preserve the tab's entire existing nested stack beneath the incoming screen instead of only its first route. The WORKSPACE_NAVIGATOR branch (which deliberately seeds a fresh WORKSPACES_LIST to avoid the reveal flash from #90985) stays exactly as it is:
letbackTargetRoutes: NavigationPartialRoute[]|undefined;if(focusedTargetTab.name===NAVIGATORS.WORKSPACE_NAVIGATOR){// ... existing fresh WORKSPACES_LIST seeding, unchanged ...backTargetRoutes=[{name: SCREENS.WORKSPACES_LIST, ...(existingListParams ? {params: existingListParams} : {})}];}elseif(existingNestedRoutes?.length){// Preserve the tab's entire existing stack (sidebar + any open screens) beneath the incoming// screen so back navigation returns to the screen the user was on, not the sidebar.// Drop a trailing route that duplicates the incoming screen (same name and reportID) so a// repeated pre-insert of the same destination stays idempotent.constlastExistingRoute=existingNestedRoutes.at(-1);constisTrailingDuplicateOfIncoming=lastExistingRoute?.name===newFirstRoute?.name&&(lastExistingRoute?.paramsas{reportID?: string}|undefined)?.reportID===(newFirstRoute?.paramsas{reportID?: string}|undefined)?.reportID;backTargetRoutes=isTrailingDuplicateOfIncoming ? existingNestedRoutes.slice(0,-1) : [...existingNestedRoutes];}else{backTargetRoutes=defaultSidebarRouteName ? [{name: defaultSidebarRouteName}] : undefined;}if(backTargetRoutes?.length&&newFirstRoute&&backTargetRoutes.at(0)?.name!==newFirstRoute.name){constprependedRoutes=[...backTargetRoutes, ...(newNestedRoutes??[])];mergedNestedState={...focusedTargetTab.state,routes: prependedRoutes,index: prependedRoutes.length-1};}
With this change my repro test produces [Inbox, Report(selfDM), Report(workspaceChat)] — back returns to the self-DM, matching what the non-pre-insert dismiss path already does. All 8 existing handleReplaceFullscreenUnderRHP tests (the workspace-navigator seeding suite) still pass, and the cancel path is unaffected because preInsertedOriginalTabRoute already saves and restores the full original tab route.
This also fixes the same history loss for any other flow that pre-inserts a fullscreen while a report is open, not just the track-expense whisper.
What specific scenarios should we cover in automated tests to prevent reintroducing this issue?
Unit tests in tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts:
Pre-inserting a workspace chat while another report (self-DM) is open in the Reports split keeps the previous report in the stack: [Inbox, Report(A)] + pre-insert Report(B) results in [Inbox, Report(A), Report(B)] with the index on Report(B).
Re-dispatching the same pre-insert (the hook can recompute while the RHP is open) is idempotent — no duplicate Report(B) entries.
Cancelling the confirmation restores the original tab route (self-DM back on top).
What alternative solutions did you explore? (Optional)
Gate the report pre-insert in getSubmitExpensePreMountDestinationRoute when a different report is already topmost, falling back to the plain dismissModalWithReport path (which pushes and preserves history). This fixes the bug but disables the flash-free reveal optimization for exactly the flow it was built for, and leaves the underlying history loss in the handler for other callers.
Route same-tab report pre-inserts through the handler's non-tab push branch instead of the tab rebuild. This preserves history too, but bypasses the remount machinery (markFocusedTabRouteForRemount) that prevents the push-transition flash during the reveal, so the rebuild-with-full-history above is the safer shape.
🚨 Edited by proposal-police: This proposal was edited at 2026-08-12 06:49:27 UTC.
Proposal
On the earlier withdrawal: my first proposal was auto-withdrawn against #issuecomment-5259758616. That one puts the cause in linkTo's fullscreen alignment and fixes it by threading backTo into dismissModalWithReport. It can't work on this flow: once a route is pre-inserted, submitWithDismissFirst takes its pre-inserted branch — clearFullscreenPreInsertedFlag(), a bare dismissModal(), executeWrite({shouldHandleNavigation: false}) (submitWithDismissFirst.ts#L69-L76) — so dismissModalWithReport never runs and no backTo is read. Different file, different layer. Reposting with the four questions answered and the change implemented.
What is the root cause of that problem?
getTargetTabRoute keeps exactly one route from the tab it is rebuilding — existingNestedRoutes.at(0), the sidebar — and throws away everything above it. The report the user is standing on is dropped while the RHP is still open, before Back is ever pressed.
Chain, all narrow layout:
On the confirmation step the destination is pre-mounted behind the RHP. Every term of the gate is true for this repro — narrow, isReportTopmostSplitNavigator(), destination ≠ topmost report, destination loaded — so it returns the workspace chat route: getSubmitExpensePreMountDestinationRoute.ts#L44, #L68-L92.
The hook dispatches REPLACE_FULLSCREEN_UNDER_RHP (usePreMountDestination#L63 → Navigation.ts#L1116). A report's outermost fullscreen is the TAB_NAVIGATOR, so the handler takes its tab branch and re-derives the reports tab.
The prepend at L234 does fire here, and I want to correct a claim on the thread that it doesn't: the reports split declares no initialRouteName in the linking config, so getStateFromPath('/r/<id>') resolves the split's nested routes to [Report] alone — no sidebar. I ran it: the split state comes back as {routes: [{name: 'Report', params: {reportID}}]}. So newFirstRoute is Report, backTargetRoute is Inbox, the names differ, the branch runs, and the result is [Inbox, Report(workspace)]. Both readings lose the self DM; only the mechanism differs, and getting it right matters because a fix that dedupes by route name will silently collapse two SCREENS.REPORT entries into one.
The assumption baked into getTargetTabRoute is that a tab's stack is one screen deep. True for LHN and global-create entry points, false when the RHP flow was launched from a report.
Scope is wider than the OP:
Not iOS-only. The gate is getIsNarrowLayout() with no platform branch, so Android app and narrow mWeb reproduce; wide does not, because preInsertFullscreenUnderRHP returns early there.
Not only Submit. Categorize and Share set isMovingTransactionFromTrackExpense and share this confirmation step.
Skip-confirmation too.getSkipConfirmationPreMountDestinationRoute pre-inserts a report with no topmost-report gate at all, from scan/distance/amount. Same collapse, same line — and it can pre-insert the report already on top, which the fix has to tolerate.
What changes do you think we should make in order to solve the problem?
Fix layer: the navigation state reducer. The stack is already malformed while the RHP is open, so nothing downstream can repair it — and both entry points, the eager preInsertFullscreenUnderRHP#L1116 and the submit-time revealRouteBeswitchDismissingModal#L1077, dispatch the same action, so one change covers every caller.
In getTargetTabRoute, when the incoming report lands in a reports split the user is currently standing in, keep that split's stack and put the destination on top of it instead of collapsing to the sidebar. Roughly:
preserved = existingRoutes.slice(0, focusedIndex + 1) // keyless on the focused route
routes = [...preserved, ...incomingRoutes] // deduped by name + reportID
Answering the four questions in order:
1. Preserve the whole stack up to the focused route, not the focused route alone. The sidebar has to stay at index 0 or the split navigator has nothing in its left pane, so "focused route only" isn't available. Anything above the focused index is forward history the user already popped past — resurrecting it would be a second bug. slice(0, focusedIndex + 1) is the correct window: [Inbox, Report(selfDM)] here, and [Inbox, Report(A)] (not [Inbox, A, B]) when the user had opened B and gone back to A.
2. Scope it to REPORTS_SPLIT_NAVIGATOR — and additionally to the case where that tab is the focused one. This second condition is the part I think the other proposals miss, and it's load-bearing. getTabStateWithExistingFocusedTarget#L256-L269 locates the target tab by name at #L257, so existingTabRoute is the reports tab's own route whether or not the user is looking at it — which means a guard like existingTabRoute.name === focusedTargetTab.name is always true and buys nothing. Without a real check, revealing a report while the user sits on Search would resurrect whatever report the reports tab happened to hold from earlier in the session, and Back would land there instead of the Inbox. The gate is targetTabIndex === existingTabState.index; pass it down as a parameter. WORKSPACE_NAVIGATOR keeps its keyless WORKSPACES_LIST seeding untouched, and HOME, SEARCH_FULLSCREEN_NAVIGATOR and SETTINGS_SPLIT_NAVIGATOR fall through to today's code unchanged. Depth-1 stacks also fall through, since at(0) and the preserved slice produce the same thing there.
3. The cancel path needs nothing.preInsertedOriginalTabRoute is captured from the pre-change route at #L456-L459, before the splice, and handleRemoveFullscreenUnderRHP#L519-L530 restores it wholesale. The change only touches the derived route. Backing out lands on the self DM with its original keys — covered by a test.
4. Yes, born-non-top — but only for the route that actually moves. The self DM is the mounted, visible top when the pre-insert fires, and it becomes non-top. Carrying it over with its key is exactly the #90985 shape that the WORKSPACES_LIST seeding at #L222-L230 already documents: react-native-screens reorders it top→non-top and flashes it. So it is carried keyless. This costs nothing, because markFocusedTabRouteForRemount#L376-L391 already strips the split's own key and remounts it either way — keeping the report's key would buy no preserved screen state, only the flash risk. I'd stop there rather than stripping the whole preserved prefix: the sidebar is non-top already, nothing reorders it, and today's code prepends it with its key and params intact. Keeping that identical keeps the behavioral delta to exactly one route.
One more detail that a straightforward implementation gets wrong: the tail must be deduped against the incoming route by name and reportID. Both are SCREENS.REPORT, so a name-only compare treats the self DM and the workspace chat as the same screen and drops one of them. Identity dedupe is also what makes a re-dispatched pre-insert idempotent, and what keeps the skip-confirmation callers — which can pre-insert the report already on top — from stacking it twice.
Tests.handleReplaceFullscreenUnderRHP.test.ts covers only WORKSPACE_NAVIGATOR today. I've implemented the change and added:
[Inbox, Report(A)] + pre-insert Report(B) → [Inbox, Report(A), Report(B)], index on B. Fails on main.
The preserved Report(A) comes back keyless while the sidebar keeps its key — locks in Q4.
Re-dispatching the same destination is idempotent. Fails on main.
Pre-inserting the report already on top does not stack it twice (skip-confirmation shape).
Forward history above the focused route is dropped: [Inbox, A, B] focused on A + C → [Inbox, A, C]. Fails on main.
Cancel restores [Inbox, Report(A)] with its original keys via preInsertedOriginalTabRoute. Fails on main.
Reports tab not focused → unchanged, Back still goes to the Inbox.
Depth-1 and the settings split are unchanged; the existing WORKSPACE_NAVIGATOR suite still passes.
Plus one in tests/navigation/handleReplaceFullscreenUnderRHPTests.ts, which runs the real StackRouter rather than a stub, asserting the same stack survives root rehydration with the RHP still on top.
All 8 existing workspace tests, getTabStateWithFocusedTargetTest, usePreMountDestinationTest and IOURequestStepConfirmationPageTest pass unchanged. Happy to put the branch up with before/after recordings on iOS Simulator and an Android emulator.
Also worth correcting on the issue itself: the Platforms checklist should include Android app and narrow mWeb.
What alternative solutions did you explore? (Optional)
Thread the origin report as backTo through dismissModalWithReport. Rejected: that path is bypassed once a route is pre-inserted (submitWithDismissFirst.ts#L69-L76), so nothing would read the param. It also only fixes the header button, not the hardware/system back.
Gate the report pre-insert off when the topmost report differs from the destination. Produces the right stack, but that condition is precisely when getSubmitExpensePreMountDestinationRoute is meant to fire, so it disables the instant reveal for its primary case — and it leaves the three skip-confirmation call sites broken.
Add a PUSH_FULLSCREEN_UNDER_RHP action and opt into it from the submit flow. Same end state, but it adds an action type, a hook option and a call-site branch, and has to re-implement the remount handling the replace handler already owns. The collapse is a bug in the existing builder, not a missing mode.
Add a shouldPreserveReportStack flag threaded from the confirmation screen through the hook and the action payload. Works for this entry point, but it makes correct back behavior opt-in: every current and future pre-insert caller that forgets the flag — starting with the three skip-confirmation ones — keeps the broken stack. The reducer should be right by default.
Preserve the stack for every tab, not just the reports split. Rejected as unnecessary risk: Search pre-inserts always parse to a state that already starts with SEARCH_ROOT, so a blanket rule needs extra dedupe there for no user-visible gain, and the other tabs don't hold a mid-stack screen Back is expected to reach.
📣 @NEXAITECHAU! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
Activity
MelvinBot commentedon Aug 9, 2026
Proposal
Root Cause (high confidence): When the track expense is moved to a workspace, the confirmation step "pre-mounts"/reveals the destination workspace report using the
REPLACE_FULLSCREEN_UNDER_RHPnavigation action. Because a report's outermost fullscreen route is theTAB_NAVIGATORwrapper, this action takes the tab-switch branch, which rebuilds the Reports split-navigator stack as[Sidebar, WorkspaceReport]and prepends only the sidebar as the back target — dropping the previously-focused Self DM report from the stack. After the RHP dismisses, pressing back on the workspace report pops to the sidebar (Inbox) instead of the Self DM.What is the root cause of that problem?
The tab-switch branch of
handleReplaceFullscreenUnderRHPrebuilds the Reports split navigator and keeps only the sidebar beneath the incoming report, so any report that was open before (the Self DM) is discarded from the back stack.Evidence & data flow
Flow:
TAB_NAVIGATOR[REPORTS_SPLIT_NAVIGATOR[Sidebar, SelfDM]].ChatActionableButtons.tsx(prepareTrackExpenseButton('submit')) →ReportUtils.ts:12001navigates to the participant selector RHP.ROUTES.REPORT_WITH_ID(workspace)viagetSubmitExpensePreMountDestinationRoute.ts:66-92.SubmitExpenseOrchestrator.tsxreveal/pre-insert handler →Navigation.tsrevealRouteBeforeDismissingModaldispatchesREPLACE_FULLSCREEN_UNDER_RHP.GetStateForActionHandlers.tstab-switch branch (handleReplaceFullscreenUnderRHP) +getTargetTabRouterebuild the split navigator as[Sidebar, WorkspaceReport], prepending only the sidebar and dropping Self DM.TAB_NAVIGATOR[REPORTS_SPLIT_NAVIGATOR[Sidebar, WorkspaceReport]]→ back pops to Sidebar = Inbox. 🐛Why iOS/narrow-layout only: The bug is on the fast pre-insert/reveal path. The non-pre-inserted path (
submitDismissStrategies.ts→dismissModalWithReport) PUSHes the report onto the existing split navigator, producing[Sidebar, SelfDM, WorkspaceReport]— where back correctly returns to Self DM.What changes do you think we should make in order to solve the problem?
In the tab-switch branch of
handleReplaceFullscreenUnderRHP/getTargetTabRoute(GetStateForActionHandlers.ts:207-254), when the focused tab is the Reports split navigator and a different report is already open in the central pane, preserve that previously-focused report beneath the incoming destination report (prepend the existing central-pane report, not just the sidebar). Alternatively, gate the report pre-insert/reveal so that when a different report is topmost in the Reports split navigator, the destination is PUSHed onto the existing split navigator (matching the dismiss/navigate path) instead of doing a full tab rebuild.What alternative solutions did you explore? (Optional)
Since the correct behavior already exists on the non-pre-insert dismiss path, an alternative is to skip the pre-insert/reveal optimization when the topmost split navigator already holds a different report, falling back to
dismissModalWithReport.Related issues
Note on verification: This is a code-based analysis. Because the defect is specific to the narrow-layout pre-insert/reveal fast path, the highest-signal confirmation is an on-device iOS repro dumping
navigationRef.getRootState()immediately before and after "Create expense" to confirm theREPORT_PRE_INSERThandler fires (vsDISMISS_MODAL).Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/98106)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
j3ffffff commentedon Aug 9, 2026
VERDICT: POST
ROOT_CAUSE_ONELINER: After moving a track expense to a workspace,
dismissModalWithReportcallsnavigate(workspaceChatRoute)after dismissing the RHP — but since the Inbox tab's stack already containsSCREENS.REPORT(Self DM), React Navigation'snavigateaction updates that entry's params instead of pushing a new route, so the iOS back stack becomes[INBOX → workspace chat]with Self DM erased from history.PROPOSAL_TYPE: FIRST
AUTOPOST_SAFE: YES
melvin-bot commentedon Aug 9, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (
External)melvin-bot commentedon Aug 9, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022086544859815465151
Uzaifm127 commentedon Aug 10, 2026
@ikevin127 I have investigated this issue locally and I found:
Note
I have a better and complete proposal than Melvin with two possible solutions.
I'm also able to reproduce this issue on Android native so it isn't iOS specific:
android-repro.mov
The pre-insert navigation flow is used on narrow layouts and doesn't have separate iOS or Android logic. The pre-insert flow checks
getIsNarrowLayout(), not the platform. So the same behavior can occur on Android native, iOS native, and any other narrow layout using this navigation path.I have an accurate root cause than Melvin. Basically this is the problematic part in the code:
App/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
Line 216 in c3f72ff
Here, when the existing routes are
Inbox > Self DM, the code takes only the first route, which isInbox, and combines it with the incoming Workspace chat route. Therefore, it createsInbox > Workspace chatand drops the Self DM before the user presses Back.I also found these two commits related to this:
getTargetTabRoute().Basically it was to rebuild a tab’s navigation state when a new screen is opened lazily. It tried to keep a simple back destination, usually
Inbox. It is intentionally discarding the Self DM and keeping theInbox.usePreMountDestination. That means while the user is still filling the expense details, the app starts preparing the destination report behind the confirmation modalAfter reviewing the commits, I found that
getTargetTabRouteintentionally remove theSelf DMand keeps theInboxas main page as a better fallback for enable the lazy navigation.I have two potential solutions for this:
Ignore the pre-insertion for moving track expenses (but this could prevent getting the advantage of lazy navigation or preparing the next screen earlier before modal closes in expense confirmation). So this solution comes with a tradeoff which also could be seen clearly in the following solution video:
solution.mov
Keep pre-insertion, but preserve the current report when pre-inserting a different report in the Reports tab.
better-solution.mov
ikevin127 commentedon Aug 11, 2026
Thanks both. I dug into this against latest
mainand I'm opening it up for proposals. Reasoning below.On Melvin's proposal: the root cause is correct and I verified it.
getTargetTabRouteinGetStateForActionHandlers.tstakesexistingNestedRoutes?.at(0)(line 216) and prepends only that beneath the incoming route, so[Sidebar, SelfDM]gets rebuilt as[Sidebar, WorkspaceReport]and Self DM is dropped before Back is ever pressed.Where it falls short is the solution section: it offers two opposite directions and picks neither. Preserving the previous report and skipping the pre-insert entirely are not variants of one fix, and the second one is a regression rather than a fix, since
getSubmitExpensePreMountDestinationRouteexists specifically to render the destination behind the RHP for an instant reveal.It also frames the bug as iOS-only, but the gate is
getIsNarrowLayout()with no platform branch anywhere on this path, so Android native and narrow mWeb are affected too.On Uzaifm127's comment: this is the strongest investigation on the thread, and the Android repro plus the commit archaeology are both correct and useful. But it isn't a proposal, it's missing the template sections, and the two solutions listed are the same two Melvin already listed with the same tradeoff called out. The added value is the evidence and the platform correction, not a different solution.
Why I'm opening it up: neither writeup addresses the part that actually makes this tricky. The fix lands inside a path that deliberately strips the focused tab's key in
markFocusedTabRouteForRemountto force a remount and avoid thereact-native-screenspush flash, and theWORKSPACES_LISTbranch right above it already seeds a fresh keyless route for the same reason (#90985).Prepending the previous report means that screen now mounts as part of the reveal. Uzaifm127's own commit research points out the discard was intentional, then moves past it.
A proposal I can accept needs to commit to the preserve approach and answer:
REPORTS_SPLIT_NAVIGATOR, or all tabs?getTargetTabRouteis shared andWORKSPACE_NAVIGATORalready has bespoke handlingpreInsertedOriginalTabRoutefeedshandleRemoveFullscreenUnderRHP@Uzaifm127, since you've already done the work and have the videos, please post this as a formal proposal covering the above and I'll review it first.
@MelvinBot Add
Help Wantedlabel on this issue to open it for proposals.yusufdeveloper2903 commentedon Aug 11, 2026
Proposal
What is the root cause of that problem?
In the OP's video the tracked expense is created in the Self DM, so when the whisper flow starts the report under the money‑request RHP is the Self DM. Selecting a workspace routes the flow to the confirmation step keyed to the destination report (the policy expense chat), e.g.
ReportUtils.ts#L12001. Because navigating to that RHP route also aligns the full‑screen route under the overlay (linkTo/index.ts#L222-L238), the Reports split's central report is swapped from the Self DM to the destination workspace chat — verifiable in the confirmation step, where theyusfu's expensesheader already renders under the confirmation dialog.On confirm,
navigateAfterExpenseCreatetakes the non‑global‑create branch and callsdismissModalAndOpenReportInInboxTab(destinationReportID)(navigateAfterExpenseCreate.ts#L57-L60) →dismissModalWithReport({reportID})(dismissModalAndOpenReportInInboxTab.ts#L84). Since the topmost report is now the destination (topmostReportID === reportID) and the Reports split is topmost, this hits the early branch that only dismisses the modal, without re‑navigating (Navigation.ts#L919-L925). The RHP peels off to reveal[Sidebar, workspaceChat]— the Self DM is no longer in the stack — so tapping back lands on the Inbox instead of the Self Chat.What changes do you think we should make in order to solve the problem?
Open the destination workspace chat with
backToset to the originating Self DM, so its header back button returns there regardless of what the swap left in the stack — report headers already resolve back viagoBack(backTo), so this is the deterministic mechanism the flow is missing.Thread the source report through the terminal navigation. The move‑from‑track flow already knows the origin as the draft's
linkedTrackedExpenseReportID. Pass it asbackTofromnavigateAfterExpenseCreate's dismiss branch intodismissModalAndOpenReportInInboxTab→dismissModalWithReport, building it once withROUTES.REPORT_WITH_ID.getRoute(linkedTrackedExpenseReportID).Honor it in the collapsed branch. In
dismissModalWithReportatNavigation.ts#L919-L925, when the topmost report already equalsreportIDand abackTowas supplied, re‑open it with the param instead of a bare dismiss:This is scoped to the move‑from‑track paths (
CATEGORIZE/SHARE/SUBMIT), since only they launch from a report the user expects to return to; the global‑create case passes nobackToand keeps today's "open on the inbox tab" behavior, so no other consumer ofdismissModalAndOpenReportInInboxTabchanges.What alternative solutions did you explore? (Optional)
linkTofor these RHP steps) so the terminaldismissModalWithReportpushes the destination on top of the Self DM. Fixes back navigation but touches the shared overlay logic inlinkTo, which many RHP flows rely on — higher blast radius than attaching abackTo.David-2002065 commentedon Aug 11, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On narrow layout (iOS app), after submitting a tracked expense from the self-DM to a workspace via the "Submit it to someone" whisper, the app opens the workspace chat — but pressing the back button lands on the Inbox instead of returning to the self-DM the user came from.
What is the root cause of that problem?
While the expense confirmation RHP is open on narrow layout, the destination workspace chat is pre-mounted underneath the RHP so it can be revealed instantly on dismiss:
getSubmitExpensePreMountDestinationRoutereturnsREPORT_WITH_ID(workspaceChat), andusePreMountDestinationdispatches it viaNavigation.preInsertFullscreenUnderRHP, which fires aREPLACE_FULLSCREEN_UNDER_RHPaction.Because every report route resolves to the
TAB_NAVIGATORfullscreen,handleReplaceFullscreenUnderRHPtakes the tab branch and rebuilds the Reports split navigator throughgetTargetTabRoute. That's where the history is lost: it keeps only the FIRST existing nested route (existingFirstRoute— the Inbox sidebar) as the back target and throws away every route above it — including the self-DM report the flow started from:So when the RHP dismisses and the user presses back on the workspace chat, the stack pops to the Inbox sidebar.
I verified this against the real handler with a unit test (same harness as the existing
handleReplaceFullscreenUnderRHP.test.ts): given[Inbox, Report(100)]under the RHP and a pre-insert ofReport(200), the handler returns[Inbox, Report(200)]—Report(100)is gone.What changes do you think we should make in order to solve the problem?
In
getTargetTabRoute, preserve the tab's entire existing nested stack beneath the incoming screen instead of only its first route. TheWORKSPACE_NAVIGATORbranch (which deliberately seeds a freshWORKSPACES_LISTto avoid the reveal flash from #90985) stays exactly as it is:With this change my repro test produces
[Inbox, Report(selfDM), Report(workspaceChat)]— back returns to the self-DM, matching what the non-pre-insert dismiss path already does. All 8 existinghandleReplaceFullscreenUnderRHPtests (the workspace-navigator seeding suite) still pass, and the cancel path is unaffected becausepreInsertedOriginalTabRoutealready saves and restores the full original tab route.This also fixes the same history loss for any other flow that pre-inserts a fullscreen while a report is open, not just the track-expense whisper.
What specific scenarios should we cover in automated tests to prevent reintroducing this issue?
Unit tests in
tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts:[Inbox, Report(A)]+ pre-insertReport(B)results in[Inbox, Report(A), Report(B)]with the index onReport(B).Report(B)entries.WORKSPACE_NAVIGATORseeding scenarios ([Due for payment 2026-07-13] [$250] Workspace - When new workspace is created on narrow screen, previous page is briefly visible #90985, [Due for payment 2026-07-13] iOS - Workspace - Unable to swipe back to return to previous page after creating a workspace #93003) are unchanged.What alternative solutions did you explore? (Optional)
getSubmitExpensePreMountDestinationRoutewhen a different report is already topmost, falling back to the plaindismissModalWithReportpath (which pushes and preserves history). This fixes the bug but disables the flash-free reveal optimization for exactly the flow it was built for, and leaves the underlying history loss in the handler for other callers.markFocusedTabRouteForRemount) that prevents the push-transition flash during the reveal, so the rebuild-with-full-history above is the safer shape.oqildev commentedon Aug 11, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-08-12 06:49:27 UTC.
Proposal
What is the root cause of that problem?
getTargetTabRoutekeeps exactly one route from the tab it is rebuilding —existingNestedRoutes.at(0), the sidebar — and throws away everything above it. The report the user is standing on is dropped while the RHP is still open, before Back is ever pressed.Chain, all narrow layout:
isReportTopmostSplitNavigator(), destination ≠ topmost report, destination loaded — so it returns the workspace chat route: getSubmitExpensePreMountDestinationRoute.ts#L44, #L68-L92.REPLACE_FULLSCREEN_UNDER_RHP(usePreMountDestination#L63 → Navigation.ts#L1116). A report's outermost fullscreen is theTAB_NAVIGATOR, so the handler takes its tab branch and re-derives the reports tab.getTargetTabRoute#L212-L237. The only live route carried over isexistingNestedRoutes?.at(0)#L216, used as the singlebackTargetRouteand prepended to the incoming routes at #L234-L237. Live stack[Inbox, Report(selfDM)]becomes[Inbox, Report(workspace)].The prepend at L234 does fire here, and I want to correct a claim on the thread that it doesn't: the reports split declares no
initialRouteNamein the linking config, sogetStateFromPath('/r/<id>')resolves the split's nested routes to[Report]alone — no sidebar. I ran it: the split state comes back as{routes: [{name: 'Report', params: {reportID}}]}. SonewFirstRouteisReport,backTargetRouteisInbox, the names differ, the branch runs, and the result is[Inbox, Report(workspace)]. Both readings lose the self DM; only the mechanism differs, and getting it right matters because a fix that dedupes by route name will silently collapse twoSCREENS.REPORTentries into one.The assumption baked into
getTargetTabRouteis that a tab's stack is one screen deep. True for LHN and global-create entry points, false when the RHP flow was launched from a report.Scope is wider than the OP:
getIsNarrowLayout()with no platform branch, so Android app and narrow mWeb reproduce; wide does not, becausepreInsertFullscreenUnderRHPreturns early there.isMovingTransactionFromTrackExpenseand share this confirmation step.getSkipConfirmationPreMountDestinationRoutepre-inserts a report with no topmost-report gate at all, from scan/distance/amount. Same collapse, same line — and it can pre-insert the report already on top, which the fix has to tolerate.What changes do you think we should make in order to solve the problem?
Fix layer: the navigation state reducer. The stack is already malformed while the RHP is open, so nothing downstream can repair it — and both entry points, the eager
preInsertFullscreenUnderRHP#L1116 and the submit-timerevealRouteBeswitchDismissingModal#L1077, dispatch the same action, so one change covers every caller.In
getTargetTabRoute, when the incoming report lands in a reports split the user is currently standing in, keep that split's stack and put the destination on top of it instead of collapsing to the sidebar. Roughly:Answering the four questions in order:
1. Preserve the whole stack up to the focused route, not the focused route alone. The sidebar has to stay at index 0 or the split navigator has nothing in its left pane, so "focused route only" isn't available. Anything above the focused index is forward history the user already popped past — resurrecting it would be a second bug.
slice(0, focusedIndex + 1)is the correct window:[Inbox, Report(selfDM)]here, and[Inbox, Report(A)](not[Inbox, A, B]) when the user had opened B and gone back to A.2. Scope it to
REPORTS_SPLIT_NAVIGATOR— and additionally to the case where that tab is the focused one. This second condition is the part I think the other proposals miss, and it's load-bearing.getTabStateWithExistingFocusedTarget#L256-L269 locates the target tab by name at #L257, soexistingTabRouteis the reports tab's own route whether or not the user is looking at it — which means a guard likeexistingTabRoute.name === focusedTargetTab.nameis always true and buys nothing. Without a real check, revealing a report while the user sits on Search would resurrect whatever report the reports tab happened to hold from earlier in the session, and Back would land there instead of the Inbox. The gate istargetTabIndex === existingTabState.index; pass it down as a parameter.WORKSPACE_NAVIGATORkeeps its keylessWORKSPACES_LISTseeding untouched, andHOME,SEARCH_FULLSCREEN_NAVIGATORandSETTINGS_SPLIT_NAVIGATORfall through to today's code unchanged. Depth-1 stacks also fall through, sinceat(0)and the preserved slice produce the same thing there.3. The cancel path needs nothing.
preInsertedOriginalTabRouteis captured from the pre-change route at #L456-L459, before the splice, andhandleRemoveFullscreenUnderRHP#L519-L530 restores it wholesale. The change only touches the derived route. Backing out lands on the self DM with its original keys — covered by a test.4. Yes, born-non-top — but only for the route that actually moves. The self DM is the mounted, visible top when the pre-insert fires, and it becomes non-top. Carrying it over with its key is exactly the #90985 shape that the
WORKSPACES_LISTseeding at #L222-L230 already documents: react-native-screens reorders it top→non-top and flashes it. So it is carried keyless. This costs nothing, becausemarkFocusedTabRouteForRemount#L376-L391 already strips the split's own key and remounts it either way — keeping the report's key would buy no preserved screen state, only the flash risk. I'd stop there rather than stripping the whole preserved prefix: the sidebar is non-top already, nothing reorders it, and today's code prepends it with its key and params intact. Keeping that identical keeps the behavioral delta to exactly one route.One more detail that a straightforward implementation gets wrong: the tail must be deduped against the incoming route by name and
reportID. Both areSCREENS.REPORT, so a name-only compare treats the self DM and the workspace chat as the same screen and drops one of them. Identity dedupe is also what makes a re-dispatched pre-insert idempotent, and what keeps the skip-confirmation callers — which can pre-insert the report already on top — from stacking it twice.Tests.
handleReplaceFullscreenUnderRHP.test.tscovers onlyWORKSPACE_NAVIGATORtoday. I've implemented the change and added:[Inbox, Report(A)]+ pre-insertReport(B)→[Inbox, Report(A), Report(B)], index on B. Fails onmain.Report(A)comes back keyless while the sidebar keeps its key — locks in Q4.main.[Inbox, A, B]focused on A +C→[Inbox, A, C]. Fails onmain.[Inbox, Report(A)]with its original keys viapreInsertedOriginalTabRoute. Fails onmain.WORKSPACE_NAVIGATORsuite still passes.Plus one in
tests/navigation/handleReplaceFullscreenUnderRHPTests.ts, which runs the realStackRouterrather than a stub, asserting the same stack survives root rehydration with the RHP still on top.All 8 existing workspace tests,
getTabStateWithFocusedTargetTest,usePreMountDestinationTestandIOURequestStepConfirmationPageTestpass unchanged. Happy to put the branch up with before/after recordings on iOS Simulator and an Android emulator.Also worth correcting on the issue itself: the Platforms checklist should include Android app and narrow mWeb.
What alternative solutions did you explore? (Optional)
backTothroughdismissModalWithReport. Rejected: that path is bypassed once a route is pre-inserted (submitWithDismissFirst.ts#L69-L76), so nothing would read the param. It also only fixes the header button, not the hardware/system back.getSubmitExpensePreMountDestinationRouteis meant to fire, so it disables the instant reveal for its primary case — and it leaves the three skip-confirmation call sites broken.PUSH_FULLSCREEN_UNDER_RHPaction and opt into it from the submit flow. Same end state, but it adds an action type, a hook option and a call-site branch, and has to re-implement the remount handling the replace handler already owns. The collapse is a bug in the existing builder, not a missing mode.shouldPreserveReportStackflag threaded from the confirmation screen through the hook and the action payload. Works for this entry point, but it makes correct back behavior opt-in: every current and future pre-insert caller that forgets the flag — starting with the three skip-confirmation ones — keeps the broken stack. The reducer should be right by default.SEARCH_ROOT, so a blanket rule needs extra dedupe there for no user-visible gain, and the other tabs don't hold a mid-stack screen Back is expected to reach.56 remaining items
NEXAITECHAU commentedon Aug 30, 2026
/bounty claim
Claiming. NEX Agent Co. working on this — drafting a PR now.
This is an automated claim by an AI agent (NEX Agent Co.). Wallet:
0xf47bdf9add81ff2130509ec564d3387072ba5726(Base USDC).melvin-bot commentedon Aug 30, 2026
📣 @NEXAITECHAU! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
melvin-bot commentedon Sep 1, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Sep 1, 2026
Payment Summary
Resolving PRs:
Upwork Job
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Sep 3, 2026
Payment Summary
Contributor: @nabi-ebrahimi paid $250 via Upwork
Contributor+: @ikevin127 due $250 via NewDot
@abbasifaizan70 paid, summary updated above. Thx.
https://www.upwork.com/jobs/~022086544859815465151
nabi-ebrahimi commentedon Sep 4, 2026
@mallenexpensify, Accepted the offer. thanks.
mallenexpensify commentedon Sep 4, 2026
@nabi-ebrahimi paid, summary updated above. Thx.
quinthar commentedon Sep 12, 2026
Approved $250 to @ikevin127