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-07-08] [$250] Two workspaces screens in the navigation history after creating a workspace using FAB #90776
Version Number: 9.3.74-4 Reproducible in staging?: Y Reproducible in production?: Y If this was caught during regression testing, add the test name, ID and link from BrowserStack: Email or phone of affected tester (no customers): Logs:https://stackoverflow.com/c/expensify/questions/4856 Expensify/Expensify Issue URL: Issue reported by:@ZhenjaHorbach Slack conversation (hyperlinked to channel name): #Expensify Bugs
Action Performed:
Open App
Sign in with a new account (don't create a workspace during onboarding) or remove all workspaces from the current account
Tap on FAB button > Create Workspace > Tap Confirm
Press the back button on browser twice
Expected Result:
User should return to the screen before Workspaces screen
Actual Result:
After double-clicking the back button, the user is still on Workspaces screen
Workaround:
Unknown
Platforms:
Select the officially supported platforms where the issue was reproduced:
When creating a workspace via FAB > Create Workspace > Confirm, two independent navigation dispatches both target the WORKSPACE_NAVIGATOR tab, producing a corrupted 3-deep stack instead of the correct 2-deep stack.
Step 1 (sync):pushNewlyCreatedWorkspaceUnderActiveModal dispatches CommonActions.navigate with initial: false, setting WORKSPACE_NAVIGATOR state to [WORKSPACE_SPLIT_NAVIGATOR(policyID)] — deliberately omitting WORKSPACES_LIST.
Step 2 (async via requestAnimationFrame):createWorkspaceWithPolicyDraftAndNavigateToIt calls revealRouteBeforeDismissingModal, which dispatches REPLACE_FULLSCREEN_UNDER_RHP for the same workspace route.
The collision:getAdaptedStateFromPath always prepends WORKSPACES_LIST to the parsed workspace state, so the incoming newNestedRoutes = [WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR]. Then handleReplaceFullscreenUnderRHP sees existingFirstRoute (WORKSPACE_SPLIT_NAVIGATOR, from Step 1) differs from newFirstRoute (WORKSPACES_LIST), so it treats the existing first route as a "sidebar" and prepends it — producing [WORKSPACE_SPLIT_NAVIGATOR, WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR].
Back navigation then requires three presses instead of two to leave the workspace tab.
What changes do you think we should make in order to solve the problem?
In WorkspaceConfirmationPage.tsx, when pushNewlyCreatedWorkspaceUnderActiveModal has already set up the workspace route under the modal (the !shouldShowSuccessPage branch), createWorkspaceWithPolicyDraftAndNavigateToIt should only dismiss the modal — not also reveal a route. This can be done by skipping the routeToNavigateAfterCreate param (or passing a flag) so that createWorkspaceWithPolicyDraftAndNavigateToIt calls dismissModal directly instead of revealRouteBeforeDismissingModal, since the workspace is already mounted underneath.
This eliminates the second REPLACE_FULLSCREEN_UNDER_RHP dispatch that causes the merge collision.
What alternative solutions did you explore? (Optional)
Fix the merge logic in handleReplaceFullscreenUnderRHP to detect when existingFirstRoute is not actually a sidebar screen (e.g., it's WORKSPACE_SPLIT_NAVIGATOR) and skip the prepend. This is riskier because the merge logic is shared across many navigation flows.
Have pushNewlyCreatedWorkspaceUnderActiveModal include WORKSPACES_LIST so both dispatches agree on the stack shape. This would work but defeats the initial: false optimization and could briefly flash WORKSPACES_LIST during the dismiss animation.
Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.
When the user taps FAB → Create Workspace from a non-Workspaces tab (Inbox) and confirms, WorkspaceConfirmationPage.onSubmit runs two helpers that both try to put the new workspace under the RHP, and their effects collide:
handleReplaceFullscreenUnderRHP then merges that into the existing tab state. Its sidebar-prepend logic (https://github.com/Expensify/App/blob/main/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts#L372-L388) sees that the existing first route (WORKSPACE_SPLIT_NAVIGATOR from step 1) differs from the new first route (WORKSPACES_LIST), so it prepends the existing route on top of the new state. WORKSPACE_NAVIGATOR.routes ends up as [WORKSPACE_SPLIT_NAVIGATOR, WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR] — two workspace entries surrounding WORKSPACES_LIST. That's the "two Workspaces screens in history" — pressing browser back pops to WORKSPACES_LIST, then to the duplicate WORKSPACE_SPLIT_NAVIGATOR, instead of returning to Inbox.
The two helpers were intended as alternatives, not a combo: pushNewlyCreatedWorkspaceUnderActiveModal already mounts the destination, so revealRouteBeforeDismissingModal is redundant here and is what introduces the duplicate.
What changes do you think we should make in order to solve the problem?
Drop the redundant reveal in the modal branch. The destination is already mounted under the RHP by pushNewlyCreatedWorkspaceUnderActiveModal, so we only need to dismiss the modal to expose it.
} else if (Navigation.isTopmostRouteModalScreen()) {
// `revealRouteBeforeDismissingModal` only works for fullscreen targets. Modal targets
// (e.g. workspace confirmation success) still need to open after the current RHP closes.
if (willRouteNavigateToRHP(routeToNavigate)) {
Navigation.dismissModal({
afterTransition: () => Navigation.navigate(routeToNavigate),
});
return;
}
- Navigation.revealRouteBeforeDismissingModal(routeToNavigate);+ // The fullscreen destination was already mounted under the RHP by the caller+ // (see pushNewlyCreatedWorkspaceUnderActiveModal in WorkspaceConfirmationPage).+ // Re-running it through revealRouteBeforeDismissingModal would re-parse the path,+ // which prepends WORKSPACES_LIST and duplicates WORKSPACE_SPLIT_NAVIGATOR in history.+ Navigation.dismissModal();
} else {
Navigation.navigate(routeToNavigate, {forceReplace: true});
}
The modal branch in createWorkspaceWithPolicyDraftAndNavigateToIt is reachable only from WorkspaceConfirmationPage (the other caller, setUpPoliciesAndNavigate, uses transitionFromOldDot: true and takes the first branch), and that page always pre-mounts via pushNewlyCreatedWorkspaceUnderActiveModal, so the dismiss-only path is safe here. After the fix, WORKSPACE_NAVIGATOR.routes = [WORKSPACE_SPLIT_NAVIGATOR] and a single browser back returns the user to the Inbox tab as expected.
What alternative solutions did you explore? (Optional)
Fix getAdaptedStateFromPath to skip injecting WORKSPACES_LIST for WORKSPACE_INITIAL / WORKSPACE_OVERVIEW paths. Rejected: that adapter is shared by every entry into the workspace tab (deep links, refresh, etc.) where the prepend is the correct behavior. Carving out one path would re-break the flows it was added for.
Patch the prepend in handleReplaceFullscreenUnderRHP to detect the WORKSPACE_NAVIGATOR case and dedupe. Rejected: it papers over the real issue (we shouldn't be running reveal at all when the route was already pre-mounted) and would still leave a transient three-entry state.
Remove the pushNewlyCreatedWorkspaceUnderActiveModal call and let revealRouteBeforeDismissingModal do the mounting. Rejected: that helper bails out when WORKSPACE_NAVIGATOR isn't already in the tab (targetTabIndex < 0 short-circuit), which is exactly the FAB-from-Inbox case. We'd lose the destination entirely.
🚨 Edited by proposal-police: This proposal was edited at 2026-05-15 16:14:06 UTC.
Updated after the reviewer correctly pointed out that the fix should avoid both timeout-based cleanup and FAB-owned history-state logic. The current comparison branch removes the setTimeout fallback, makes the web modal own its history guard cleanup, and makes the custom FAB menu use the existing close-callback contract instead of checking window.history.state itself.
Proposal
Please re-state the problem that we are trying to solve in this issue.
After creating a workspace from the global FAB on web, browser history contains an extra Workspaces entry. From the newly created workspace, the first browser back should land on the Workspaces list and the second browser back should return to the screen that was active before the FAB flow, such as Inbox/Home. Instead, the second back can still land on Workspaces.
This reproduces on mobile web and desktop web. It does not apply to native Android/iOS for this RCA because native modal wrappers do not use the browser window.history guard.
What is the root cause of that problem?
The root cause is a shared web modal history lifecycle race. The FAB is only the repro path that exposes it.
On web, Modal.showModal() pushes a same-URL browser-history guard entry when shouldHandleNavigationBack is enabled:
That guard is intended to let browser back close the modal/popover. The FAB menu uses this path because FABPopoverMenu renders PopoverWithMeasuredContent with shouldHandleNavigationBack.
The existing cleanup mechanism is in web Modal.hideModal(). When the modal closes and the active history state still has shouldGoBack, it calls onModalHide() and then schedules window.history.back() in setTimeout(0). The timeout also re-checks window.history.state.shouldGoBack before calling history.back().
That ordering is the bug. BaseModal.hideModal() calls onModalHide() and then immediately calls onModalDidClose(). onModalDidClose() runs callbacks stored by Modal.close(callback). Several menu flows use those callbacks to navigate after the modal closes.
So the failing sequence is:
Modal.showModal: push same-URL shouldGoBack guard
FAB item press: selected action is allowed to navigate
Modal.hideModal: schedules history.back() for later
Modal.close callback: navigates to /workspace/confirmation first
Modal.hideModal timeout: active state is now /workspace/confirmation without shouldGoBack
Modal.hideModal: skips history.back()
In plain terms: the selected action can navigate to /workspace/confirmation before the deferred window.history.back() cleanup runs. Once that navigation pushes a new browser-history state, the active entry no longer has shouldGoBack, so the timeout skips window.history.back(). The old same-URL guard entry remains underneath the workspace flow, which is why browser back sees an extra Workspaces entry.
Desktop web has the same underlying issue. NewWorkspaceMenuItem previously passed shouldCallAfterModalHide={shouldUseNarrowLayout}, so on wide layout the selected action could run immediately instead of using the modal close callback path. That means desktop could also navigate before the modal history guard was cleaned up.
This is not caused by workspace creation, the workspace API, pushNewlyCreatedWorkspaceUnderActiveModal(), initial: false, or revealRouteBeforeDismissingModal(). Those workspace-stack theories do not explain why the existing window.history.back() cleanup is skipped, and they do not cover the reviewer-confirmed desktop web repro.
What changes do you think we should make in order to solve the problem?
Fix the ordering in the shared web modal lifecycle so the browser-history guard is popped before modal close callbacks can navigate.
In web src/components/Modal/index.tsx, make the browser-history guard owned by the modal instance. Push the guard with both shouldGoBack and a modalHistoryGuardID, and track whether that modal still owns an active guard.
Still in web Modal/index.tsx, replace the deferred setTimeout(() => window.history.back()) cleanup with event-based cleanup only. On programmatic hide, if the current history state is this modal's active guard, remove the normal popstate close listener, call window.history.back(), and resolve only from the one-shot popstate event. There is no timeout fallback. If the current state is not this modal's guard anymore, do not blindly call history.back() because that could go back from a route that was already pushed by another flow.
In src/components/Modal/BaseModal.tsx, allow onModalHide() to return a Promise and await it before calling onModalDidClose(). This guarantees callbacks registered through Modal.close(callback) do not run until the web history guard has been popped.
In src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx, remove the FAB-owned window.history.state check. The custom FAB menu should mirror the existing PopoverMenu close-callback contract: FAB items that navigate/open app flows opt into shouldCallAfterModalHide, and FABPopoverMenu runs their selected action through close(callback). The only Safari exception preserved is for user-activation-sensitive external Travel links, matching the existing Safari exception pattern used elsewhere for direct browser actions.
With this ordering, the history stack is cleaned before the workspace confirmation route is pushed:
close FAB modal
pop this modal's same-URL shouldGoBack guard via one-shot popstate
run Modal.close callback
navigate to workspace confirmation
That removes the stale guard entry for both mobile web and desktop web while keeping the fix in the shared web modal lifecycle.
What alternative solutions did you explore? (Optional)
I first suspected a duplicate workspace stack caused by pushNewlyCreatedWorkspaceUnderActiveModal() and revealRouteBeforeDismissingModal(). That was not the correct root cause: it does not explain the skipped web history cleanup, and it misses the desktop web repro.
I then tried a FAB-local fix that checked window.history.state.shouldGoBack and waited for popstate inside FABPopoverMenu. That proved the timing issue, but it was still too local and matched the reviewer's concern. The production fix should not make FAB own browser-history cleanup. The updated approach keeps history cleanup in web Modal, waits for that shared cleanup before onModalDidClose() callbacks can navigate, and only makes the custom FAB menu use the same close-callback contract already used by menu flows that open another route/modal after selection.
I traced the bug to src/libs/actions/App.ts in the createWorkspaceWithPolicyDraftAndNavigateToIt() function (line 578).
When the user taps FAB → Create Workspace, the WorkspaceConfirmationPage is pushed as an RHP (Right Hand Panel) modal screen. On confirmation, createWorkspaceWithPolicyDraftAndNavigateToIt() is called. At line 633, the function checks Navigation.isTopmostRouteModalScreen(), which returns true because WorkspaceConfirmation is a modal. It then calls Navigation.dismissModal({ afterTransition: () => Navigation.navigate(routeToNavigate) }) (line 634–636).
The bug lives inside that afterTransition callback on line 635: after dismissModal() pops the modal from the stack, Navigation.navigate(routeToNavigate)pushesWORKSPACE_INITIAL as a brand-new entry on top of the existing navigation history. This duplicates the workspace route in the stack. Pressing back once returns to the dismissed-modal position and pressing back again lands on the same Workspaces screen — the user never escapes it with two back presses.
Contrast this with the non-modal branch at line 638, which already passes { forceReplace: true } to Navigation.navigate(), preventing exactly this stack duplication. The modal branch simply never received the same treatment.
What changes do you think we should make in order to solve the problem?
The fix is frontend-only and touches a single line: src/libs/actions/App.ts line 635.
Inside the afterTransition callback of Navigation.dismissModal(), change Navigation.navigate(routeToNavigate) to Navigation.navigate(routeToNavigate, {forceReplace: true}). This makes the post-dismiss navigation replace the current top of the history stack instead of pushing a duplicate entry, exactly mirroring the forceReplace: true logic already in the else branch at line 638.
Files touched: src/libs/actions/App.ts (one line change).
Files NOT touched: WorkspaceConfirmationPage.tsx, any navigator configs, ROUTES.ts, SCREENS.ts, onboarding flows, or any other action files.
Platforms affected: all platforms (web, iOS, Android) since the nav stack duplication is platform-agnostic.
What alternative solutions did you explore? (Optional)
No other proposals exist on this issue yet, so there are no competing angles to compare.
I considered whether dismissModal() itself should accept a forceReplace parameter or whether the fix belonged inside NavigationRoot, but that would be a broader refactor. The minimal and correct fix is scoping forceReplace: true to the afterTransition callback, consistent with the existing logic pattern already present in the same function.
Please re-state the problem that we are trying to solve in this issue.
On narrow layout (mobile web), after creating a workspace via the FAB on the Inbox (or any other tab), pressing the browser back button twice still leaves the user on a Workspaces-related screen instead of returning them to the screen they were on before they opened the FAB. The user reports that there are two "Workspaces" screens accumulated in the navigation history when there should be a single one (or none, since they came from Inbox).
What is the root cause of that problem?
The narrow-layout submit path of WorkspaceConfirmationPage.tsx calls TWO navigation primitives back-to-back that both try to insert the new workspace's screen underneath the open RHP, and the second one duplicates routes that the first one already set up.
The first primitive is pushNewlyCreatedWorkspaceUnderActiveModal(SCREENS.WORKSPACE.INITIAL, policyID). It dispatches a CommonActions.navigate targeted at the TAB_NAVIGATOR's state with initial: false, which switches the active tab to WORKSPACE_NAVIGATOR and deliberately suppresses the WORKSPACES_LIST seed route so that back from the new workspace bypasses the list:
After this dispatch the WORKSPACE_NAVIGATOR tab's nested state has exactly one route: [WORKSPACE_SPLIT_NAVIGATOR(WORKSPACE.INITIAL)] (no WORKSPACES_LIST).
The second primitive is createWorkspaceWithPolicyDraftAndNavigateToIt, which is invoked unconditionally right after the first call. On narrow layout it eventually hits the else if (Navigation.isTopmostRouteModalScreen()) branch and, because WORKSPACE_INITIAL is a fullscreen route (not an RHP), falls through to Navigation.revealRouteBeforeDismissingModal(routeToNavigate):
The handler computes a "sidebar" prepend for the target tab so that back from the new screen lands on a sensible root. The merge logic reads the EXISTING tab's first nested route to use as the sidebar:
Because the first primitive already overwrote the WORKSPACE_NAVIGATOR tab with initial: false, the existing first nested route is now WORKSPACE_SPLIT_NAVIGATOR (the new workspace itself) — not the expected WORKSPACES_LIST sidebar. The new state from getStateFromPath(WORKSPACE_INITIAL), on the other hand, does start with WORKSPACES_LIST (the stack navigator's seeded initial route), so sidebarRoute.name !== newFirstRoute.name is true and the handler prepends.
The final WORKSPACE_NAVIGATOR nested routes become [WORKSPACE_SPLIT_NAVIGATOR(new), WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR(new)] with index: 2. After the RHP dismisses, browser back pops within WORKSPACE_NAVIGATOR: back #1 lands on WORKSPACES_LIST, back #2 lands on a duplicate WORKSPACE_SPLIT_NAVIGATOR — still inside the Workspaces section. That is exactly the "two Workspaces screens" the reporter sees.
The two primitives are doing the same job (pre-mount the new workspace under the open RHP so the dismiss animation reveals it). Running both in sequence is the bug.
What changes do you think we should make in order to solve the problem?
In WorkspaceConfirmationPage.tsx, on narrow layout, the call to pushNewlyCreatedWorkspaceUnderActiveModal already fully sets up the destination state under the RHP. The subsequent revealRouteBeforeDismissingModal inside createWorkspaceWithPolicyDraftAndNavigateToIt is redundant on this path and is what causes the duplication. The fix is to stop running both: when pushNewlyCreatedWorkspaceUnderActiveModal has done the pre-mount on narrow layout, the post-create step should just dismiss the modal, not re-insert.
The most direct way to express this is to add an explicit signal from the helper to the action. Concretely:
In pushNewlyCreatedWorkspaceUnderActiveModal, when the pre-mount dispatch succeeds, mark the existing isFullscreenPreInsertedUnderRHP flag in Navigation.ts (via a new exported setter, e.g. markFullscreenPreInsertedUnderRHP()). This is the same flag the IOU flow already uses to coordinate with revealRouteBeforeDismissingModal (see how navigateAfterExpenseCreate reads Navigation.getIsFullscreenPreInsertedUnderRHP() to choose between "just dismiss" and "reveal + dismiss").
In createWorkspaceWithPolicyDraftAndNavigateToIt, inside the else if (Navigation.isTopmostRouteModalScreen()) branch, before falling through to revealRouteBeforeDismissingModal, check Navigation.getIsFullscreenPreInsertedUnderRHP(). If it is set, call Navigation.clearFullscreenPreInsertedFlag() and Navigation.dismissModal() instead. This skips the duplicating REPLACE_FULLSCREEN_UNDER_RHP step and dismisses the RHP, revealing the already-mounted workspace screen.
With this change, the narrow-layout post-create state of WORKSPACE_NAVIGATOR stays at [WORKSPACE_SPLIT_NAVIGATOR(new)] (single entry, index: 0). After the RHP dismisses, browser back pops out of the single-entry WORKSPACE_NAVIGATOR stack and, via the tab navigator's backBehavior="fullHistory", returns to the previously focused tab (Inbox) — which is the expected behavior described in the bug report.
The wide-layout path is unaffected because it never enters revealRouteBeforeDismissingModal (the target route there is an RHP route, so the existing willRouteNavigateToRHP branch handles it via dismissModal({afterTransition: navigate})).
Other call sites of createWorkspaceWithPolicyDraftAndNavigateToIt (e.g. the OldDot transition path in App.ts around line 807) are also unaffected because they do not call pushNewlyCreatedWorkspaceUnderActiveModal and therefore do not set the flag.
What alternative solutions did you explore? (Optional)
An alternative is to fix this entirely inside handleReplaceFullscreenUnderRHP: when the existing tab is the SAME tab being targeted and its currently focused leaf route already matches the new target's focused leaf route (same screen name and matching key params, e.g. policyID), treat the action as a no-op for the state and let the caller just dismiss the modal. This would make REPLACE_FULLSCREEN_UNDER_RHP self-protect against duplicate pre-mounts and benefit any future flow that double-mounts.
The downside is it relies on params-equality heuristics across an action-state boundary that React Navigation's stack router does not normally expose, and it would hide the underlying coordination problem rather than fix it. Using the existing isFullscreenPreInsertedUnderRHP flag — already designed for exactly this kind of "two primitives, one outcome" coordination — keeps the contract explicit and matches the pattern used by the expense-create flow.
changed the title [-]Two workspaces screens in the navigation history after creating a workspace using FAB[/-][+][$250] Two workspaces screens in the navigation history after creating a workspace using FAB[/+]on May 15, 2026
⚠️@yusufdeveloper2903 Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).
Noting for the record: the PR that fixed this issue, #92492, caused a regression on Android mWeb Chrome. Tapping a chat in the search list opened Spend or Inbox instead of the chat. Reported in #100463, fixed in #100593.
Replacing the raw window.history back-guard was the right fix here. The problem was that an older Search Router helper had been removing that guard's history entry by hand. Once the entry no longer existed, the helper's extra back() popped one screen too many.
The fix was to delete the helper, which had been unnecessary since this PR merged. Details are in my comment on #92492.
Activity
MelvinBot commentedon May 15, 2026
Proposal
What is the root cause of that problem?
When creating a workspace via FAB > Create Workspace > Confirm, two independent navigation dispatches both target the
WORKSPACE_NAVIGATORtab, producing a corrupted 3-deep stack instead of the correct 2-deep stack.Step 1 (sync):
pushNewlyCreatedWorkspaceUnderActiveModaldispatchesCommonActions.navigatewithinitial: false, settingWORKSPACE_NAVIGATORstate to[WORKSPACE_SPLIT_NAVIGATOR(policyID)]— deliberately omittingWORKSPACES_LIST.Step 2 (async via
requestAnimationFrame):createWorkspaceWithPolicyDraftAndNavigateToItcallsrevealRouteBeforeDismissingModal, which dispatchesREPLACE_FULLSCREEN_UNDER_RHPfor the same workspace route.The collision:
getAdaptedStateFromPathalways prependsWORKSPACES_LISTto the parsed workspace state, so the incomingnewNestedRoutes=[WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR]. ThenhandleReplaceFullscreenUnderRHPseesexistingFirstRoute(WORKSPACE_SPLIT_NAVIGATOR, from Step 1) differs fromnewFirstRoute(WORKSPACES_LIST), so it treats the existing first route as a "sidebar" and prepends it — producing[WORKSPACE_SPLIT_NAVIGATOR, WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR].Back navigation then requires three presses instead of two to leave the workspace tab.
What changes do you think we should make in order to solve the problem?
In
WorkspaceConfirmationPage.tsx, whenpushNewlyCreatedWorkspaceUnderActiveModalhas already set up the workspace route under the modal (the!shouldShowSuccessPagebranch),createWorkspaceWithPolicyDraftAndNavigateToItshould only dismiss the modal — not also reveal a route. This can be done by skipping therouteToNavigateAfterCreateparam (or passing a flag) so thatcreateWorkspaceWithPolicyDraftAndNavigateToItcallsdismissModaldirectly instead ofrevealRouteBeforeDismissingModal, since the workspace is already mounted underneath.This eliminates the second
REPLACE_FULLSCREEN_UNDER_RHPdispatch that causes the merge collision.What alternative solutions did you explore? (Optional)
handleReplaceFullscreenUnderRHPto detect whenexistingFirstRouteis not actually a sidebar screen (e.g., it'sWORKSPACE_SPLIT_NAVIGATOR) and skip the prepend. This is riskier because the merge logic is shared across many navigation flows.pushNewlyCreatedWorkspaceUnderActiveModalincludeWORKSPACES_LISTso both dispatches agree on the stack shape. This would work but defeats theinitial: falseoptimization and could briefly flashWORKSPACES_LISTduring the dismiss animation.Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.David-2002065 commentedon May 15, 2026
Proposal
What is the root cause of that problem?
When the user taps FAB → Create Workspace from a non-Workspaces tab (Inbox) and confirms,
WorkspaceConfirmationPage.onSubmitruns two helpers that both try to put the new workspace under the RHP, and their effects collide:pushNewlyCreatedWorkspaceUnderActiveModalis called first (https://github.com/Expensify/App/blob/main/src/pages/workspace/WorkspaceConfirmationPage.tsx#L45-L49). It dispatchesCommonActions.navigatewithinitial: false, pushingWORKSPACE_NAVIGATORontoTAB_NAVIGATORwith the workspace split nested inside — crucially without seedingWORKSPACES_LIST. So at this pointWORKSPACE_NAVIGATOR.routes = [WORKSPACE_SPLIT_NAVIGATOR].Then
createWorkspaceWithPolicyDraftAndNavigateToItreaches the modal branch (https://github.com/Expensify/App/blob/main/src/libs/actions/App.ts#L637-L647) and, because the workspace route isn't an RHP target, callsNavigation.revealRouteBeforeDismissingModal(workspaceRoute). That dispatchesREPLACE_FULLSCREEN_UNDER_RHP, which re-parses/workspaces/:policyIDthroughgetAdaptedStateFromPath. That adapter unconditionally prependsWORKSPACES_LISTto anyWORKSPACE_NAVIGATORit sees (https://github.com/Expensify/App/blob/main/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts#L312-L330), so the freshly parsed state arrives as[WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR].handleReplaceFullscreenUnderRHPthen merges that into the existing tab state. Its sidebar-prepend logic (https://github.com/Expensify/App/blob/main/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts#L372-L388) sees that the existing first route (WORKSPACE_SPLIT_NAVIGATORfrom step 1) differs from the new first route (WORKSPACES_LIST), so it prepends the existing route on top of the new state.WORKSPACE_NAVIGATOR.routesends up as[WORKSPACE_SPLIT_NAVIGATOR, WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR]— two workspace entries surroundingWORKSPACES_LIST. That's the "two Workspaces screens in history" — pressing browser back pops toWORKSPACES_LIST, then to the duplicateWORKSPACE_SPLIT_NAVIGATOR, instead of returning to Inbox.The two helpers were intended as alternatives, not a combo:
pushNewlyCreatedWorkspaceUnderActiveModalalready mounts the destination, sorevealRouteBeforeDismissingModalis redundant here and is what introduces the duplicate.What changes do you think we should make in order to solve the problem?
Drop the redundant reveal in the modal branch. The destination is already mounted under the RHP by
pushNewlyCreatedWorkspaceUnderActiveModal, so we only need to dismiss the modal to expose it.In
src/libs/actions/App.ts(https://github.com/Expensify/App/blob/main/src/libs/actions/App.ts#L637-L647):} else if (Navigation.isTopmostRouteModalScreen()) { // `revealRouteBeforeDismissingModal` only works for fullscreen targets. Modal targets // (e.g. workspace confirmation success) still need to open after the current RHP closes. if (willRouteNavigateToRHP(routeToNavigate)) { Navigation.dismissModal({ afterTransition: () => Navigation.navigate(routeToNavigate), }); return; } - Navigation.revealRouteBeforeDismissingModal(routeToNavigate); + // The fullscreen destination was already mounted under the RHP by the caller + // (see pushNewlyCreatedWorkspaceUnderActiveModal in WorkspaceConfirmationPage). + // Re-running it through revealRouteBeforeDismissingModal would re-parse the path, + // which prepends WORKSPACES_LIST and duplicates WORKSPACE_SPLIT_NAVIGATOR in history. + Navigation.dismissModal(); } else { Navigation.navigate(routeToNavigate, {forceReplace: true}); }The modal branch in
createWorkspaceWithPolicyDraftAndNavigateToItis reachable only fromWorkspaceConfirmationPage(the other caller,setUpPoliciesAndNavigate, usestransitionFromOldDot: trueand takes the first branch), and that page always pre-mounts viapushNewlyCreatedWorkspaceUnderActiveModal, so the dismiss-only path is safe here. After the fix,WORKSPACE_NAVIGATOR.routes = [WORKSPACE_SPLIT_NAVIGATOR]and a single browser back returns the user to the Inbox tab as expected.What alternative solutions did you explore? (Optional)
getAdaptedStateFromPathto skip injectingWORKSPACES_LISTforWORKSPACE_INITIAL/WORKSPACE_OVERVIEWpaths. Rejected: that adapter is shared by every entry into the workspace tab (deep links, refresh, etc.) where the prepend is the correct behavior. Carving out one path would re-break the flows it was added for.handleReplaceFullscreenUnderRHPto detect theWORKSPACE_NAVIGATORcase and dedupe. Rejected: it papers over the real issue (we shouldn't be running reveal at all when the route was already pre-mounted) and would still leave a transient three-entry state.pushNewlyCreatedWorkspaceUnderActiveModalcall and letrevealRouteBeforeDismissingModaldo the mounting. Rejected: that helper bails out whenWORKSPACE_NAVIGATORisn't already in the tab (targetTabIndex < 0short-circuit), which is exactly the FAB-from-Inbox case. We'd lose the destination entirely.KJ21-ENG commentedon May 15, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-05-15 16:14:06 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
After creating a workspace from the global FAB on web, browser history contains an extra Workspaces entry. From the newly created workspace, the first browser back should land on the Workspaces list and the second browser back should return to the screen that was active before the FAB flow, such as Inbox/Home. Instead, the second back can still land on Workspaces.
This reproduces on mobile web and desktop web. It does not apply to native Android/iOS for this RCA because native modal wrappers do not use the browser
window.historyguard.What is the root cause of that problem?
The root cause is a shared web modal history lifecycle race. The FAB is only the repro path that exposes it.
On web,
Modal.showModal()pushes a same-URL browser-history guard entry whenshouldHandleNavigationBackis enabled:That guard is intended to let browser back close the modal/popover. The FAB menu uses this path because
FABPopoverMenurendersPopoverWithMeasuredContentwithshouldHandleNavigationBack.The existing cleanup mechanism is in web
Modal.hideModal(). When the modal closes and the active history state still hasshouldGoBack, it callsonModalHide()and then scheduleswindow.history.back()insetTimeout(0). The timeout also re-checkswindow.history.state.shouldGoBackbefore callinghistory.back().That ordering is the bug.
BaseModal.hideModal()callsonModalHide()and then immediately callsonModalDidClose().onModalDidClose()runs callbacks stored byModal.close(callback). Several menu flows use those callbacks to navigate after the modal closes.So the failing sequence is:
In plain terms: the selected action can navigate to
/workspace/confirmationbefore the deferredwindow.history.back()cleanup runs. Once that navigation pushes a new browser-history state, the active entry no longer hasshouldGoBack, so the timeout skipswindow.history.back(). The old same-URL guard entry remains underneath the workspace flow, which is why browser back sees an extra Workspaces entry.Desktop web has the same underlying issue.
NewWorkspaceMenuItempreviously passedshouldCallAfterModalHide={shouldUseNarrowLayout}, so on wide layout the selected action could run immediately instead of using the modal close callback path. That means desktop could also navigate before the modal history guard was cleaned up.This is not caused by workspace creation, the workspace API,
pushNewlyCreatedWorkspaceUnderActiveModal(),initial: false, orrevealRouteBeforeDismissingModal(). Those workspace-stack theories do not explain why the existingwindow.history.back()cleanup is skipped, and they do not cover the reviewer-confirmed desktop web repro.What changes do you think we should make in order to solve the problem?
Fix the ordering in the shared web modal lifecycle so the browser-history guard is popped before modal close callbacks can navigate.
In web
src/components/Modal/index.tsx, make the browser-history guard owned by the modal instance. Push the guard with bothshouldGoBackand amodalHistoryGuardID, and track whether that modal still owns an active guard.Still in web
Modal/index.tsx, replace the deferredsetTimeout(() => window.history.back())cleanup with event-based cleanup only. On programmatic hide, if the current history state is this modal's active guard, remove the normal popstate close listener, callwindow.history.back(), and resolve only from the one-shotpopstateevent. There is no timeout fallback. If the current state is not this modal's guard anymore, do not blindly callhistory.back()because that could go back from a route that was already pushed by another flow.In
src/components/Modal/BaseModal.tsx, allowonModalHide()to return a Promise and await it before callingonModalDidClose(). This guarantees callbacks registered throughModal.close(callback)do not run until the web history guard has been popped.In
src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx, remove the FAB-ownedwindow.history.statecheck. The custom FAB menu should mirror the existingPopoverMenuclose-callback contract: FAB items that navigate/open app flows opt intoshouldCallAfterModalHide, andFABPopoverMenuruns their selected action throughclose(callback). The only Safari exception preserved is for user-activation-sensitive external Travel links, matching the existing Safari exception pattern used elsewhere for direct browser actions.With this ordering, the history stack is cleaned before the workspace confirmation route is pushed:
That removes the stale guard entry for both mobile web and desktop web while keeping the fix in the shared web modal lifecycle.
Comparison branch: main...KJ21-ENG:KJ21-ENG/90776-fab-history-guard-fix
What alternative solutions did you explore? (Optional)
I first suspected a duplicate workspace stack caused by
pushNewlyCreatedWorkspaceUnderActiveModal()andrevealRouteBeforeDismissingModal(). That was not the correct root cause: it does not explain the skipped web history cleanup, and it misses the desktop web repro.I then tried a FAB-local fix that checked
window.history.state.shouldGoBackand waited forpopstateinsideFABPopoverMenu. That proved the timing issue, but it was still too local and matched the reviewer's concern. The production fix should not make FAB own browser-history cleanup. The updated approach keeps history cleanup in webModal, waits for that shared cleanup beforeonModalDidClose()callbacks can navigate, and only makes the custom FAB menu use the same close-callback contract already used by menu flows that open another route/modal after selection.Abdulloh0109 commentedon May 15, 2026
Proposal
What is the root cause of that problem?
I traced the bug to
src/libs/actions/App.tsin thecreateWorkspaceWithPolicyDraftAndNavigateToIt()function (line 578).When the user taps FAB → Create Workspace, the
WorkspaceConfirmationPageis pushed as an RHP (Right Hand Panel) modal screen. On confirmation,createWorkspaceWithPolicyDraftAndNavigateToIt()is called. At line 633, the function checksNavigation.isTopmostRouteModalScreen(), which returnstruebecauseWorkspaceConfirmationis a modal. It then callsNavigation.dismissModal({ afterTransition: () => Navigation.navigate(routeToNavigate) })(line 634–636).The bug lives inside that
afterTransitioncallback on line 635: afterdismissModal()pops the modal from the stack,Navigation.navigate(routeToNavigate)pushesWORKSPACE_INITIALas a brand-new entry on top of the existing navigation history. This duplicates the workspace route in the stack. Pressing back once returns to the dismissed-modal position and pressing back again lands on the same Workspaces screen — the user never escapes it with two back presses.Contrast this with the non-modal branch at line 638, which already passes
{ forceReplace: true }toNavigation.navigate(), preventing exactly this stack duplication. The modal branch simply never received the same treatment.What changes do you think we should make in order to solve the problem?
The fix is frontend-only and touches a single line:
src/libs/actions/App.tsline 635.Inside the
afterTransitioncallback ofNavigation.dismissModal(), changeNavigation.navigate(routeToNavigate)toNavigation.navigate(routeToNavigate, {forceReplace: true}). This makes the post-dismiss navigation replace the current top of the history stack instead of pushing a duplicate entry, exactly mirroring theforceReplace: truelogic already in theelsebranch at line 638.Files touched:
src/libs/actions/App.ts(one line change).Files NOT touched:
WorkspaceConfirmationPage.tsx, any navigator configs,ROUTES.ts,SCREENS.ts, onboarding flows, or any other action files.Platforms affected: all platforms (web, iOS, Android) since the nav stack duplication is platform-agnostic.
What alternative solutions did you explore? (Optional)
No other proposals exist on this issue yet, so there are no competing angles to compare.
I considered whether
dismissModal()itself should accept aforceReplaceparameter or whether the fix belonged insideNavigationRoot, but that would be a broader refactor. The minimal and correct fix is scopingforceReplace: trueto theafterTransitioncallback, consistent with the existing logic pattern already present in the same function.melvin-bot commentedon May 15, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (
External)wildan-m commentedon May 15, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On narrow layout (mobile web), after creating a workspace via the FAB on the Inbox (or any other tab), pressing the browser back button twice still leaves the user on a Workspaces-related screen instead of returning them to the screen they were on before they opened the FAB. The user reports that there are two "Workspaces" screens accumulated in the navigation history when there should be a single one (or none, since they came from Inbox).
What is the root cause of that problem?
The narrow-layout submit path of
WorkspaceConfirmationPage.tsxcalls TWO navigation primitives back-to-back that both try to insert the new workspace's screen underneath the open RHP, and the second one duplicates routes that the first one already set up.App/src/pages/workspace/WorkspaceConfirmationPage.tsx
Lines 45 to 49 in b1e71d7
The first primitive is
pushNewlyCreatedWorkspaceUnderActiveModal(SCREENS.WORKSPACE.INITIAL, policyID). It dispatches aCommonActions.navigatetargeted at theTAB_NAVIGATOR's state withinitial: false, which switches the active tab toWORKSPACE_NAVIGATORand deliberately suppresses theWORKSPACES_LISTseed route so that back from the new workspace bypasses the list:App/src/libs/Navigation/helpers/pushNewlyCreatedWorkspaceUnderActiveModal.ts
Lines 18 to 40 in b1e71d7
After this dispatch the
WORKSPACE_NAVIGATORtab's nested state has exactly one route:[WORKSPACE_SPLIT_NAVIGATOR(WORKSPACE.INITIAL)](noWORKSPACES_LIST).The second primitive is
createWorkspaceWithPolicyDraftAndNavigateToIt, which is invoked unconditionally right after the first call. On narrow layout it eventually hits theelse if (Navigation.isTopmostRouteModalScreen())branch and, becauseWORKSPACE_INITIALis a fullscreen route (not an RHP), falls through toNavigation.revealRouteBeforeDismissingModal(routeToNavigate):App/src/libs/actions/App.ts
Lines 637 to 650 in b1e71d7
revealRouteBeforeDismissingModaldispatchesREPLACE_FULLSCREEN_UNDER_RHP, which goes intohandleReplaceFullscreenUnderRHP's tab-switch branch (target isTAB_NAVIGATOR > WORKSPACE_NAVIGATOR > ...):App/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
Lines 376 to 429 in b1e71d7
The handler computes a "sidebar" prepend for the target tab so that back from the new screen lands on a sensible root. The merge logic reads the EXISTING tab's first nested route to use as the sidebar:
App/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts
Lines 405 to 415 in b1e71d7
Because the first primitive already overwrote the
WORKSPACE_NAVIGATORtab withinitial: false, the existing first nested route is nowWORKSPACE_SPLIT_NAVIGATOR(the new workspace itself) — not the expectedWORKSPACES_LISTsidebar. The new state fromgetStateFromPath(WORKSPACE_INITIAL), on the other hand, does start withWORKSPACES_LIST(the stack navigator's seeded initial route), sosidebarRoute.name !== newFirstRoute.nameis true and the handler prepends.The final
WORKSPACE_NAVIGATORnested routes become[WORKSPACE_SPLIT_NAVIGATOR(new), WORKSPACES_LIST, WORKSPACE_SPLIT_NAVIGATOR(new)]withindex: 2. After the RHP dismisses, browser back pops withinWORKSPACE_NAVIGATOR: back #1 lands onWORKSPACES_LIST, back #2 lands on a duplicateWORKSPACE_SPLIT_NAVIGATOR— still inside the Workspaces section. That is exactly the "two Workspaces screens" the reporter sees.The two primitives are doing the same job (pre-mount the new workspace under the open RHP so the dismiss animation reveals it). Running both in sequence is the bug.
What changes do you think we should make in order to solve the problem?
In
WorkspaceConfirmationPage.tsx, on narrow layout, the call topushNewlyCreatedWorkspaceUnderActiveModalalready fully sets up the destination state under the RHP. The subsequentrevealRouteBeforeDismissingModalinsidecreateWorkspaceWithPolicyDraftAndNavigateToItis redundant on this path and is what causes the duplication. The fix is to stop running both: whenpushNewlyCreatedWorkspaceUnderActiveModalhas done the pre-mount on narrow layout, the post-create step should just dismiss the modal, not re-insert.The most direct way to express this is to add an explicit signal from the helper to the action. Concretely:
In
pushNewlyCreatedWorkspaceUnderActiveModal, when the pre-mount dispatch succeeds, mark the existingisFullscreenPreInsertedUnderRHPflag inNavigation.ts(via a new exported setter, e.g.markFullscreenPreInsertedUnderRHP()). This is the same flag the IOU flow already uses to coordinate withrevealRouteBeforeDismissingModal(see hownavigateAfterExpenseCreatereadsNavigation.getIsFullscreenPreInsertedUnderRHP()to choose between "just dismiss" and "reveal + dismiss").In
createWorkspaceWithPolicyDraftAndNavigateToIt, inside theelse if (Navigation.isTopmostRouteModalScreen())branch, before falling through torevealRouteBeforeDismissingModal, checkNavigation.getIsFullscreenPreInsertedUnderRHP(). If it is set, callNavigation.clearFullscreenPreInsertedFlag()andNavigation.dismissModal()instead. This skips the duplicatingREPLACE_FULLSCREEN_UNDER_RHPstep and dismisses the RHP, revealing the already-mounted workspace screen.With this change, the narrow-layout post-create state of
WORKSPACE_NAVIGATORstays at[WORKSPACE_SPLIT_NAVIGATOR(new)](single entry,index: 0). After the RHP dismisses, browser back pops out of the single-entryWORKSPACE_NAVIGATORstack and, via the tab navigator'sbackBehavior="fullHistory", returns to the previously focused tab (Inbox) — which is the expected behavior described in the bug report.The wide-layout path is unaffected because it never enters
revealRouteBeforeDismissingModal(the target route there is an RHP route, so the existingwillRouteNavigateToRHPbranch handles it viadismissModal({afterTransition: navigate})).Other call sites of
createWorkspaceWithPolicyDraftAndNavigateToIt(e.g. the OldDot transition path inApp.tsaround line 807) are also unaffected because they do not callpushNewlyCreatedWorkspaceUnderActiveModaland therefore do not set the flag.Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/90776-workspace-fab-back-duplicate?expand=1
What alternative solutions did you explore? (Optional)
An alternative is to fix this entirely inside
handleReplaceFullscreenUnderRHP: when the existing tab is the SAME tab being targeted and its currently focused leaf route already matches the new target's focused leaf route (same screen name and matching key params, e.g.policyID), treat the action as a no-op for the state and let the caller just dismiss the modal. This would makeREPLACE_FULLSCREEN_UNDER_RHPself-protect against duplicate pre-mounts and benefit any future flow that double-mounts.The downside is it relies on params-equality heuristics across an action-state boundary that React Navigation's stack router does not normally expose, and it would hide the underlying coordination problem rather than fix it. Using the existing
isFullscreenPreInsertedUnderRHPflag — already designed for exactly this kind of "two primitives, one outcome" coordination — keeps the contract explicit and matches the pattern used by the expense-create flow.[-]Two workspaces screens in the navigation history after creating a workspace using FAB[/-][+][$250] Two workspaces screens in the navigation history after creating a workspace using FAB[/+]melvin-bot commentedon May 15, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022055291115579121632
github-actions commentedon May 15, 2026
111 remaining items
melvin-bot commentedon Jul 7, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Jul 7, 2026
Payment Summary
Upwork Job
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Jul 9, 2026
@roryabraham 👀 on requested price increase above, thx
@Eskalifer1 can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022075270583843889974
roryabraham commentedon Jul 9, 2026
Definitely a bear of a pull request, let me get a buddy-check internally
roryabraham commentedon Jul 9, 2026
@Eskalifer1 we suggest a payment of $1000 for this issue/pull request
Eskalifer1 commentedon Jul 9, 2026
Hi @roryabraham, totally works for me! I'll leave the decision up to you)
mallenexpensify commentedon Jul 13, 2026
Payment Summary
Contributor+: @Eskalifer1 paid $1000 via Upwork
^ Test case created. Thx
TaduJR commentedon Sep 25, 2026
Noting for the record: the PR that fixed this issue, #92492, caused a regression on Android mWeb Chrome. Tapping a chat in the search list opened Spend or Inbox instead of the chat. Reported in #100463, fixed in #100593.
Replacing the raw
window.historyback-guard was the right fix here. The problem was that an older Search Router helper had been removing that guard's history entry by hand. Once the entry no longer existed, the helper's extraback()popped one screen too many.The fix was to delete the helper, which had been unnecessary since this PR merged. Details are in my comment on #92492.