Skip to content

[Due for payment 2026-07-08] [$250] Two workspaces screens in the navigation history after creating a workspace using FAB #90776

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


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:

  1. Open App
  2. Sign in with a new account (don't create a workspace during onboarding) or remove all workspaces from the current account
  3. Tap on FAB button > Create Workspace > Tap Confirm
  4. 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:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

592555452-d09d79b4-035e-4f58-a757-a3f2a668eb8a.mov
ScreenRecording_05-15-2026.09-51-56_1.MP4

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022055291115579121632
  • Upwork Job ID: 2055291115579121632
  • Last Price Increase: 2026-05-22
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 15, 2026
  2. MelvinBot commented on May 15, 2026

    @MelvinBot
    Contributor

    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_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)

    1. 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.
    2. 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.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 15, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 15, 2026
  5. David-2002065 commented on May 15, 2026

    @David-2002065
    Contributor

    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.onSubmit runs two helpers that both try to put the new workspace under the RHP, and their effects collide:

    1. pushNewlyCreatedWorkspaceUnderActiveModal is called first (https://github.com/Expensify/App/blob/main/src/pages/workspace/WorkspaceConfirmationPage.tsx#L45-L49). It dispatches CommonActions.navigate with initial: false, pushing WORKSPACE_NAVIGATOR onto TAB_NAVIGATOR with the workspace split nested inside — crucially without seeding WORKSPACES_LIST. So at this point WORKSPACE_NAVIGATOR.routes = [WORKSPACE_SPLIT_NAVIGATOR].

    2. Then createWorkspaceWithPolicyDraftAndNavigateToIt reaches 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, calls Navigation.revealRouteBeforeDismissingModal(workspaceRoute). That dispatches REPLACE_FULLSCREEN_UNDER_RHP, which re-parses /workspaces/:policyID through getAdaptedStateFromPath. That adapter unconditionally prepends WORKSPACES_LIST to any WORKSPACE_NAVIGATOR it 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].

    3. 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.

    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 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.
  6. KJ21-ENG commented on May 15, 2026

    @KJ21-ENG
    Contributor

    🚨 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:

    window.history.pushState({...currentState, shouldGoBack: true}, '', null);

    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.

    1. 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.

    2. 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.

    3. 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.

    4. 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.

    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() 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.

  7. Abdulloh0109 commented on May 15, 2026

    @Abdulloh0109
    Contributor

    Proposal

    What is the root cause of that problem?

    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) pushes WORKSPACE_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.

  8. melvin-bot commented on May 15, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (External)

  9. wildan-m commented on May 15, 2026

    @wildan-m
    Contributor

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

    if (!shouldShowSuccessPage) {
    // Mount the new workspace under this RHP so the dismiss animation reveals it instead of flashing WORKSPACES_LIST.
    const targetScreen = isSmallScreenWidth ? SCREENS.WORKSPACE.INITIAL : SCREENS.WORKSPACE.PROFILE;
    pushNewlyCreatedWorkspaceUnderActiveModal(targetScreen, policyID);
    }

    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:

    function pushNewlyCreatedWorkspaceUnderActiveModal(targetScreen: WorkspaceTargetScreen, policyID: string) {
    const rootState = navigationRef.getRootState();
    const tabRoute = rootState?.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR);
    const tabStateKey = tabRoute?.state?.key;
    if (!tabStateKey) {
    return;
    }
    navigationRef.dispatch({
    ...CommonActions.navigate({
    name: NAVIGATORS.WORKSPACE_NAVIGATOR,
    params: {
    initial: false,
    screen: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR,
    params: {
    screen: targetScreen,
    params: {policyID},
    },
    },
    }),
    target: tabStateKey,
    });
    }

    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):

    App/src/libs/actions/App.ts

    Lines 637 to 650 in b1e71d7

    } 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);
    } else {
    Navigation.navigate(routeToNavigate, {forceReplace: true});
    }

    revealRouteBeforeDismissingModal dispatches REPLACE_FULLSCREEN_UNDER_RHP, which goes into handleReplaceFullscreenUnderRHP's tab-switch branch (target is TAB_NAVIGATOR > WORKSPACE_NAVIGATOR > ...):

    if (targetRoute.name === NAVIGATORS.TAB_NAVIGATOR) {
    const tabNavIndex = routesWithoutRHP.findLastIndex((r) => r.name === NAVIGATORS.TAB_NAVIGATOR);
    if (tabNavIndex < 0) {
    return null;
    }
    const existingTabRoute = routesWithoutRHP.at(tabNavIndex);
    const existingTabState = existingTabRoute?.state as NavigationState | undefined;
    if (!existingTabRoute || !existingTabState?.routes?.length) {
    return null;
    }
    const focusedTargetTab = getFocusedRouteFromNavigatorState(targetRoute.state);
    if (!focusedTargetTab) {
    return null;
    }
    const targetTabIndex = existingTabState.routes.findIndex((r) => r.name === focusedTargetTab.name);
    if (targetTabIndex < 0) {
    return null;
    }
    // Only update the target tab's nested state; all other tabs are left intact.
    const updatedTabRoutes = existingTabState.routes.map((r, i) => {
    if (i !== targetTabIndex) {
    return r;
    }
    // Prepend the existing sidebar/root route (e.g. Inbox) to the incoming state when
    // it starts with a different screen, so back navigation from the new screen
    // lands on the sidebar. When the existing tab doesn't have nested
    // routes (e.g. cold-start through a deep link that opens straight into a modal),
    // fall back to the split navigator's default sidebar route so there is still
    // something to pop back to.
    let mergedNestedState = focusedTargetTab.state;
    const existingNestedRoutes = (r.state as PartialState<NavigationState> | undefined)?.routes;
    const newNestedRoutes = focusedTargetTab.state?.routes;
    const existingFirstRoute = existingNestedRoutes?.at(0);
    const newFirstRoute = newNestedRoutes?.at(0);
    const defaultSidebarRouteName = r.name in SPLIT_TO_SIDEBAR ? SPLIT_TO_SIDEBAR[r.name as keyof typeof SPLIT_TO_SIDEBAR] : undefined;
    const sidebarRoute: NavigationPartialRoute | undefined = existingFirstRoute ?? (defaultSidebarRouteName ? {name: defaultSidebarRouteName} : undefined);
    if (sidebarRoute && newFirstRoute && sidebarRoute.name !== newFirstRoute.name) {
    const prependedRoutes = [sidebarRoute, ...(newNestedRoutes ?? [])];
    mergedNestedState = {...focusedTargetTab.state, routes: prependedRoutes, index: prependedRoutes.length - 1};
    }
    // Strip any RN deep-link hint chain from `r.params`; otherwise RN would run a
    // follow-up NAVIGATE from it and override the `state` we splice below.
    const sanitizedRoute = withSanitizedDeepLinkParams(r, focusedTargetTab.params as Record<string, unknown> | undefined);
    return {
    ...sanitizedRoute,
    ...(mergedNestedState !== undefined ? {state: mergedNestedState as typeof r.state} : {}),
    };
    });
    const updatedTabState = {...existingTabState, routes: updatedTabRoutes, index: targetTabIndex};
    const updatedTabRoute = {...existingTabRoute, state: updatedTabState} as StackNavigationState<ParamListBase>['routes'][number];
    // Save original route so handleRemoveFullscreenUnderRHP can fully restore it on cancel.
    preInsertedOriginalTabRoute = existingTabRoute;
    const newRoutes = [...routesWithoutRHP.slice(0, tabNavIndex), updatedTabRoute, ...routesWithoutRHP.slice(tabNavIndex + 1), rhpRoute];
    return stackRouter.getRehydratedState({...state, routes: newRoutes, index: newRoutes.length - 1}, configOptions);

    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:

    let mergedNestedState = focusedTargetTab.state;
    const existingNestedRoutes = (r.state as PartialState<NavigationState> | undefined)?.routes;
    const newNestedRoutes = focusedTargetTab.state?.routes;
    const existingFirstRoute = existingNestedRoutes?.at(0);
    const newFirstRoute = newNestedRoutes?.at(0);
    const defaultSidebarRouteName = r.name in SPLIT_TO_SIDEBAR ? SPLIT_TO_SIDEBAR[r.name as keyof typeof SPLIT_TO_SIDEBAR] : undefined;
    const sidebarRoute: NavigationPartialRoute | undefined = existingFirstRoute ?? (defaultSidebarRouteName ? {name: defaultSidebarRouteName} : undefined);
    if (sidebarRoute && newFirstRoute && sidebarRoute.name !== newFirstRoute.name) {
    const prependedRoutes = [sidebarRoute, ...(newNestedRoutes ?? [])];
    mergedNestedState = {...focusedTargetTab.state, routes: prependedRoutes, index: prependedRoutes.length - 1};
    }

    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:

    1. 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").

    2. 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.

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

  10. 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
  11. melvin-bot commented on May 15, 2026

    @melvin-bot
  12. github-actions commented on May 15, 2026

    @github-actions
    Contributor

    ⚠️ @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).

  13. 111 remaining items

  14. melvin-bot commented on Jul 7, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  15. melvin-bot commented on Jul 7, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    • Contributor: @WojtekBoman does not require payment (Contractor)
    • ROLE: @Eskalifer1 paid $(AMOUNT) via Upwork (LINK)

    BugZero Checklist (@mallenexpensify)

    • I have verified the correct assignees and roles are listed above and updated the necessary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants/2055291115579121632/hired)
    • I have verified the PR was not reverted
    • I have applied any discounts due to bugs/regressions introduced by this PR
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  16. mallenexpensify commented on Jul 9, 2026

    @mallenexpensify
    Contributor

    @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

  17. roryabraham commented on Jul 9, 2026

    @roryabraham
    Contributor

    Definitely a bear of a pull request, let me get a buddy-check internally

  18. roryabraham commented on Jul 9, 2026

    @roryabraham
    Contributor

    @Eskalifer1 we suggest a payment of $1000 for this issue/pull request

  19. Eskalifer1 commented on Jul 9, 2026

    @Eskalifer1
    Contributor

    Hi @roryabraham, totally works for me! I'll leave the decision up to you)

  20. mallenexpensify commented on Jul 13, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @Eskalifer1 paid $1000 via Upwork

    ^ Test case created. Thx

  21. TaduJR commented on Sep 25, 2026

    @TaduJR
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions