diff --git a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts index a0edee5f9bbe..724f69ffb459 100644 --- a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts +++ b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts @@ -1,5 +1,5 @@ import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import {isFullScreenName, isWorkspaceNavigatorRouteName} from '@libs/Navigation/helpers/isNavigatorName'; +import {isWorkspaceNavigatorRouteName} from '@libs/Navigation/helpers/isNavigatorName'; import {getWorkspacesTabStateFromSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import navigateToWorkspacesPage from '@libs/Navigation/helpers/navigateToWorkspacesPage'; import {getTabState} from '@libs/Navigation/helpers/tabNavigatorUtils'; @@ -33,48 +33,25 @@ function useRestoreWorkspacesTabOnNavigate() { // Find the last route the user had open in the Workspaces tab (workspace, domain, or list). // Priority: live nav state (root level) -> inside TabNavigator -> preserved state -> session storage. const rootState = navigationRef.isReady() ? navigationRef.getRootState() : undefined; - const routeState = (() => { - const topmostFullScreenRoute = rootState?.routes?.findLast((route) => isFullScreenName(route.name)); - if (!topmostFullScreenRoute) { - return {}; + const lastTabNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); + const lastWorkspacesTabNavigatorRoute = (() => { + if (lastTabNavigatorRoute) { + const workspaceNavigatorRoute = getTabState(lastTabNavigatorRoute)?.routes?.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); + const workspaceNavigatorState = workspaceNavigatorRoute?.state ?? (workspaceNavigatorRoute?.key ? getPreservedNavigatorState(workspaceNavigatorRoute.key) : undefined); + const lastWorkspaceRoute = workspaceNavigatorState?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); + if (lastWorkspaceRoute) { + return lastWorkspaceRoute; + } } - // Multiple TAB_NAVIGATOR instances can coexist in the root stack — when navigation from - // inside an RHP targets a tab, linkTo PUSHes a fresh TabNavigator above the modal, and that - // new instance's WORKSPACE_NAVIGATOR slot starts empty. Older instances kept alive by - // ensureTabNavigatorRoutes still hold the previous workspace state, so flatten every - // workspace route from every TabNavigator in stack order and take the most recent one. - const lastWorkspaceRoute = (rootState?.routes ?? []) - .filter((route) => route.name === NAVIGATORS.TAB_NAVIGATOR) - .flatMap((tabNavigatorRoute) => { - const workspaceNavigatorRoute = getTabState(tabNavigatorRoute)?.routes?.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); - const workspaceNavigatorState = workspaceNavigatorRoute?.state ?? (workspaceNavigatorRoute?.key ? getPreservedNavigatorState(workspaceNavigatorRoute.key) : undefined); - return workspaceNavigatorState?.routes?.filter((route) => isWorkspaceNavigatorRouteName(route.name)) ?? []; - }) - .at(-1); - - if (lastWorkspaceRoute) { - const tabState = lastWorkspaceRoute.state ?? (lastWorkspaceRoute.key ? getPreservedNavigatorState(lastWorkspaceRoute.key) : undefined); - return {lastWorkspacesTabNavigatorRoute: lastWorkspaceRoute, workspacesTabState: tabState, topmostFullScreenRoute}; - } - - // Fall back to session storage when no workspace route exists anywhere in the navigation tree. - // getStateFromPath returns state rooted at TAB_NAVIGATOR, so we must drill into it first. + // Fall back to session storage. Shape mirrors the live nav state: TabNavigator -> WorkspaceNavigator -> WorkspaceSplitNavigator. const sessionTabNavigatorRoute = getWorkspacesTabStateFromSessionStorage()?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); - const sessionRoute = getTabState(sessionTabNavigatorRoute) - ?.routes?.findLast((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR) - ?.state?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); - if (sessionRoute) { - return {lastWorkspacesTabNavigatorRoute: sessionRoute, workspacesTabState: sessionRoute.state, topmostFullScreenRoute}; - } - - return {topmostFullScreenRoute}; + const sessionWorkspaceNavigatorRoute = sessionTabNavigatorRoute?.state?.routes?.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); + return sessionWorkspaceNavigatorRoute?.state?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); })(); - const {lastWorkspacesTabNavigatorRoute, workspacesTabState, topmostFullScreenRoute} = routeState; - // If the last route was a specific workspace or domain, extract its ID from params - const params = workspacesTabState?.routes?.at(0)?.params as + const params = lastWorkspacesTabNavigatorRoute?.state?.routes?.at(0)?.params as | WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.INITIAL] | DomainSplitNavigatorParamList[typeof SCREENS.DOMAIN.INITIAL]; const paramsPolicyID = params && 'policyID' in params ? params.policyID : undefined; @@ -94,8 +71,7 @@ function useRestoreWorkspacesTabOnNavigate() { policy: lastViewedPolicy, domain: lastViewedDomain, lastWorkspacesTabNavigatorRoute, - topmostFullScreenRoute, - workspacesTabState, + lastTabNavigatorRoute, }); }; } diff --git a/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts b/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts index 54f420e8ee39..6f738518b187 100644 --- a/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts +++ b/src/libs/Navigation/AppNavigator/createWorkspaceNavigator/WorkspaceRouter.ts @@ -1,16 +1,48 @@ -import type {RouterConfigOptions} from '@react-navigation/native'; +import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {StackRouter} from '@react-navigation/native'; +import {getWorkspacesTabStateFromSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getPreservedNavigatorState} from '@navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; import type WorkspaceNavigatorRouterOptions from './types'; +/** + * Builds the WorkspaceNavigator initial state from the saved Workspaces-tab path in sessionStorage — + * the routes the user had open in the tab before navigating away. Prepends WORKSPACES_LIST when the + * saved path didn't include it so the back-stack works (swipe back from a workspace returns to the list). + * Returns `undefined` on native (no sessionStorage), when nothing is saved, or when the parsed + * state has no WORKSPACE_NAVIGATOR slot. + */ +function buildWorkspaceInitialStateFromSessionStorage(): PartialState> | undefined { + const sessionState = getWorkspacesTabStateFromSessionStorage(); + const restoredState = sessionState?.routes?.find((r) => r.name === NAVIGATORS.TAB_NAVIGATOR)?.state?.routes?.find((r) => r.name === NAVIGATORS.WORKSPACE_NAVIGATOR)?.state as + | PartialState> + | undefined; + if (!restoredState?.routes?.length) { + return undefined; + } + if (restoredState.routes.some((r) => r.name === SCREENS.WORKSPACES_LIST)) { + return restoredState; + } + const routes = [{name: SCREENS.WORKSPACES_LIST}, ...restoredState.routes]; + return {routes, index: routes.length - 1}; +} + function WorkspaceRouter(options: WorkspaceNavigatorRouterOptions) { const stackRouter = StackRouter(options); return { ...stackRouter, - getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { + getInitialState(configOptions: RouterConfigOptions) { const preservedState = getPreservedNavigatorState(options.parentRoute.key); - return preservedState ?? stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); + if (preservedState) { + return preservedState; + } + const sessionState = buildWorkspaceInitialStateFromSessionStorage(); + if (sessionState) { + return stackRouter.getRehydratedState(sessionState, configOptions); + } + return stackRouter.getInitialState(configOptions); }, }; } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 7de8f5c0e21e..34eed088e942 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -80,10 +80,17 @@ function parseAndLogRoute(state: NavigationState) { const lastRoute = state.routes.at(-1); const activeTabName = getActiveTabName(lastRoute); - if (activeTabName === NAVIGATORS.WORKSPACE_NAVIGATOR) { - saveWorkspacesTabPathToSessionStorage(currentPath); - } else if (activeTabName === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR) { - saveSettingsTabPathToSessionStorage(currentPath); + // Skip saving when the focused route is the navigator itself (no nested screen yet), e.g. during + // the intermediate state right after `TabActions.jumpTo(WORKSPACE_NAVIGATOR)` and before the + // navigator mounts. The path collapses to `/` in that moment and would clobber the real path + // that WorkspaceRouter.getInitialState needs to read. + const isFocusedOnEmptyNavigator = focusedRoute?.name === activeTabName; + if (!isFocusedOnEmptyNavigator) { + if (activeTabName === NAVIGATORS.WORKSPACE_NAVIGATOR) { + saveWorkspacesTabPathToSessionStorage(currentPath); + } else if (activeTabName === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR) { + saveSettingsTabPathToSessionStorage(currentPath); + } } // Fullstory Page navigation tracking diff --git a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts index eade2355570d..852f69b32c01 100644 --- a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts +++ b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts @@ -1,28 +1,25 @@ -import {findFocusedRoute, StackActions} from '@react-navigation/native'; import type {NavigationState, PartialState} from '@react-navigation/native'; +import {findFocusedRoute, StackActions, TabActions} from '@react-navigation/native'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; +// eslint-disable-next-line no-restricted-imports -- TransitionTracker is needed here to sequence the tab jump after the popToTop transition completes, so WorkspaceInitialPage appears before the tab becomes visible. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isPendingDeletePolicy, shouldShowPolicy as shouldShowPolicyUtil} from '@libs/PolicyUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Domain, Policy} from '@src/types/onyx'; import getActiveTabName from './getActiveTabName'; -import getPathFromState from './getPathFromState'; -import {getTabState} from './tabNavigatorUtils'; +import {saveWorkspacesTabPathToSessionStorage} from './lastVisitedTabPathUtils'; type RouteType = NavigationState['routes'][number] | PartialState['routes'][number]; -/** - * Wraps a leaf navigation state in successive ancestor navigators (outermost first). - * Used to reconstruct the linking-config hierarchy that `getPathFromState` walks when - * resolving a state subtree to a URL. - */ -function wrapStateInNavigators(state: PartialState, navigators: readonly string[]): PartialState { - return navigators.reduceRight>((acc, name) => ({routes: [{name, state: acc}], index: 0}), state); +function jumpToWorkspacesTab(tabNavStateKey: string) { + navigationRef.dispatch({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: tabNavStateKey, + }); } type Params = { @@ -31,64 +28,44 @@ type Params = { policy?: Policy; domain?: Domain; lastWorkspacesTabNavigatorRoute?: RouteType; - topmostFullScreenRoute?: RouteType; - /** - * The full WorkspaceSplitNavigator inner state captured by the hook. - * Wrapped in a synthetic outer node and fed to `getPathFromState` to reconstruct - * the deep URL the user was on (e.g. `/workspaces/POLICY_ID/workflows`). Navigating - * via that URL goes through `getStateFromPath` which produces a fully-formed - * navigation state — bypassing custom router actions that don't seed nested state - * when pushing a fresh TabNavigator on top of an existing fullscreen stack. - */ - workspacesTabState?: NavigationState | PartialState; + lastTabNavigatorRoute?: RouteType; }; -const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, policy, domain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute, workspacesTabState}: Params) => { +const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, policy, domain, lastWorkspacesTabNavigatorRoute, lastTabNavigatorRoute}: Params) => { const rootState = navigationRef.getRootState(); const focusedRoute = rootState ? findFocusedRoute(rootState) : undefined; const isOnWorkspacesList = focusedRoute?.name === SCREENS.WORKSPACES_LIST; - if (!topmostFullScreenRoute || isOnWorkspacesList) { + if (!lastTabNavigatorRoute || isOnWorkspacesList) { // Not in a main workspace navigation context or the workspaces list page is already displayed, so do nothing. return; } - // Pop to the older TAB_NAVIGATOR holding the workspace state. Target the root stack - // explicitly so POP bypasses SearchFullscreenNavigator's PUSH_PARAMS interceptor. - // https://github.com/Expensify/App/issues/89009 - if (rootState) { - const topRootIndex = rootState.index ?? rootState.routes.length - 1; - const olderTabIdx = rootState.routes.findLastIndex((route, idx) => { - if (idx >= topRootIndex || route.name !== NAVIGATORS.TAB_NAVIGATOR) { - return false; - } - const tabState = getTabState(route as Parameters[0]); - const focusedTab = tabState?.routes?.at(tabState.index ?? 0); - if (focusedTab?.name !== NAVIGATORS.WORKSPACE_NAVIGATOR) { - return false; - } - const wsState = focusedTab.state ?? (focusedTab.key ? getPreservedNavigatorState(focusedTab.key) : undefined); - return !!wsState?.routes?.length; - }); - if (olderTabIdx !== -1) { - navigationRef.dispatch({...StackActions.pop(topRootIndex - olderTabIdx), target: rootState.key}); - return; - } - } - // Check if user is already on a workspace or domain inside WORKSPACE_NAVIGATOR (within TabNavigator) const isWorkspaceOrDomainOnTop = lastWorkspacesTabNavigatorRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || lastWorkspacesTabNavigatorRoute?.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR; - const activeTabName = topmostFullScreenRoute.name === NAVIGATORS.TAB_NAVIGATOR ? getActiveTabName(topmostFullScreenRoute as Parameters[0]) : undefined; + const activeTabName = getActiveTabName(lastTabNavigatorRoute as Parameters[0]); if (activeTabName === NAVIGATORS.WORKSPACE_NAVIGATOR && isWorkspaceOrDomainOnTop) { // Already inside a workspace or domain: go back to the list. Navigation.goBack(ROUTES.WORKSPACES_LIST.route); return; } + // Restore WORKSPACE_SPLIT_NAVIGATOR / DOMAIN_SPLIT_NAVIGATOR by jumping to the WORKSPACE_NAVIGATOR + // tab in-place. URL-based navigation would go through `getStateFromPath` and push a brand-new + // TAB_NAVIGATOR on top of the existing one, which is wasteful. On cold start the slot has no + // nested state yet; jumping focuses it and WorkspaceRouter.getInitialState reads the last-visited + // sub-page from sessionStorage so the user still lands on it. + const existingTabNavStateKey = (lastTabNavigatorRoute.state as NavigationState | undefined)?.key; + interceptAnonymousUser(() => { - // No workspace found in nav state: go to list. + // Cold start: no nested state yet. Jump to the Workspaces tab and let WorkspaceRouter.getInitialState + // restore the last-visited sub-page (or fall back to WORKSPACES_LIST when there's nothing saved). if (!lastWorkspacesTabNavigatorRoute) { + if (existingTabNavStateKey) { + jumpToWorkspacesTab(existingTabNavStateKey); + return; + } Navigation.navigate(ROUTES.WORKSPACES_LIST.route); return; } @@ -104,34 +81,32 @@ const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, poli return; } - if (policy?.id) { - // Synthesize a URL from the captured WorkspaceSplitNavigator inner state and navigate - // to it. URL-based navigation goes through `getStateFromPath`, which produces a fully - // formed nested state and reliably handles pushing a fresh TabNavigator on top of an - // existing fullscreen stack. The state has to be wrapped with its full ancestor chain - // (TAB_NAVIGATOR > WORKSPACE_NAVIGATOR > WORKSPACE_SPLIT_NAVIGATOR) so `getPathFromState` - // can match the linking-config hierarchy and produce a real URL like - // `/workspaces/POLICY_ID/workflows`; otherwise the resolver falls back to navigator - // names as path segments and the result hits 404. Narrow layouts skip the deep-restore - // and go to the workspace's initial page (mirrors mobile behavior). - const wrappedState = - !shouldUseNarrowLayout && workspacesTabState - ? wrapStateInNavigators(workspacesTabState as PartialState, [ - NAVIGATORS.TAB_NAVIGATOR, - NAVIGATORS.WORKSPACE_NAVIGATOR, - NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, - ]) - : undefined; - const targetPath = (wrappedState ? getPathFromState(wrappedState) : ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)) as Route; - Navigation.navigate(targetPath); + if (policy?.id && existingTabNavStateKey) { + const focusedWorkspaceSplitRouteName = lastWorkspacesTabNavigatorRoute.state ? findFocusedRoute(lastWorkspacesTabNavigatorRoute.state)?.name : undefined; + const isOnWorkspaceInitial = focusedWorkspaceSplitRouteName === SCREENS.WORKSPACE.INITIAL; + if (shouldUseNarrowLayout && !isOnWorkspaceInitial) { + if (lastWorkspacesTabNavigatorRoute.state?.key) { + // Live state: pop the workspace split to WorkspaceInitialPage while the tab is + // still hidden, then jump to the tab. Resetting first prevents any sub-page from + // flashing before WorkspaceInitialPage appears. + navigationRef.dispatch({...StackActions.popToTop(), target: lastWorkspacesTabNavigatorRoute.state.key}); + TransitionTracker.runAfterTransitions({callback: () => jumpToWorkspacesTab(existingTabNavStateKey), waitForUpcomingTransition: true}); + return; + } + // Session-storage state: the WorkspaceSplitNavigator isn't mounted yet, so we can't + // dispatch popToTop. Overwrite the saved path with the workspace-initial URL so + // WorkspaceRouter/SplitRouter rehydrate to just WORKSPACE_INITIAL when they mount. + saveWorkspacesTabPathToSessionStorage(ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)); + } + jumpToWorkspacesTab(existingTabNavStateKey); + return; } - return; } // Domain route found: try to restore last domain screen. if (lastWorkspacesTabNavigatorRoute.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR) { - if (domain?.accountID !== undefined) { - Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(domain.accountID)); + if (domain?.accountID && existingTabNavStateKey) { + jumpToWorkspacesTab(existingTabNavStateKey); return; } } diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 9c940b676613..01faa3a0343f 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -180,7 +180,7 @@ function AccessOrNotFoundWrapper({ }, true); const isPolicyNotAccessible = !isPolicyAccessible(policy, login); - const shouldShowNotFoundPage = (!isMoneyRequest && !isFromGlobalCreate && isPolicyNotAccessible) || !isPageAccessible || shouldBeBlocked; + const shouldShowNotFoundPage = isFocused && ((!isMoneyRequest && !isFromGlobalCreate && isPolicyNotAccessible) || !isPageAccessible || shouldBeBlocked); // We only update the feature state if it isn't pending. // This is because the feature state changes several times during the creation of a workspace, while we are waiting for a response from the backend. // Without this, we can be unexpectedly navigated to the More Features page. @@ -212,7 +212,6 @@ function AccessOrNotFoundWrapper({ }; return ; } - if (shouldShowNotFoundPage) { return ( { if (policyDraft?.id || !isFocused) { return; diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 30a7cb5765e0..bfcea2cba5c8 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -165,7 +165,14 @@ function WorkspacePageWithSections({ const shouldShowPolicy = useMemo(() => shouldShowPolicyUtil(policy, false, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); + const shouldShow = useMemo(() => { + // Don't trigger the not-found view while the screen is in the background — prevents unexpected + // navigation when the workspace is deleted from another device while this screen is unfocused. + if (!isFocused) { + return false; + } + // If the policy object doesn't exist or contains only error data, we shouldn't display it. if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) { return true; @@ -174,7 +181,7 @@ function WorkspacePageWithSections({ // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace return (!isEmptyObject(policy) && !canEditWorkspaceSettings(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !(isPendingDelete && !prevIsPendingDelete)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policy, shouldShowNonAdmin, shouldShowPolicy]); + }, [isFocused, policy, shouldShowNonAdmin, shouldShowPolicy]); const handleOnBackButtonPress = () => { if (shouldShow) { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx index bd1f077a3b2a..65e74b779214 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx @@ -327,7 +327,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail title={translate('workspace.common.viewTransactions')} style={styles.mt3} onPress={() => { - Navigation.navigate( + Navigation.revealRouteBeforeDismissingModal( ROUTES.SEARCH_ROOT.getRoute({ query: buildCannedSearchQuery({ type: CONST.SEARCH.DATA_TYPES.EXPENSE, diff --git a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts index ed6dc281ef39..464d507ae18a 100644 --- a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts +++ b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts @@ -1,7 +1,9 @@ +import {StackActions, TabActions} from '@react-navigation/native'; import {renderHook} from '@testing-library/react-native'; -import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; +// eslint-disable-next-line no-restricted-imports -- TransitionTracker is mocked here to assert the tab-jump sequencing after popToTop in navigateToWorkspacesPage. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -12,10 +14,6 @@ jest.mock('@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigat getPreservedNavigatorState: jest.fn(() => undefined), })); -jest.mock('@libs/Navigation/helpers/lastVisitedTabPathUtils', () => ({ - getWorkspacesTabStateFromSessionStorage: jest.fn(() => undefined), -})); - const mockResponsiveLayout = jest.fn(() => ({shouldUseNarrowLayout: false})); jest.mock('@hooks/useResponsiveLayout', () => () => mockResponsiveLayout()); @@ -35,7 +33,10 @@ jest.mock('@libs/Navigation/navigationRef', () => ({ jest.mock('@react-navigation/native', () => ({ findFocusedRoute: jest.fn(() => ({name: 'some-screen'})), StackActions: { - pop: jest.fn((count: number) => ({type: 'POP', payload: {count}})), + popToTop: jest.fn(() => ({type: 'POP_TO_TOP'})), + }, + TabActions: { + jumpTo: jest.fn((name: string) => ({type: 'JUMP_TO', payload: {name}})), }, })); @@ -44,9 +45,16 @@ jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), })); -jest.mock('@libs/Navigation/helpers/getPathFromState', () => ({ +jest.mock('@libs/Navigation/TransitionTracker', () => ({ __esModule: true, - default: jest.fn(), + default: { + runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { + callback(); + return {cancel: () => {}}; + }), + startTransition: jest.fn(), + endTransition: jest.fn(), + }, })); jest.mock('@libs/PolicyUtils', () => ({ @@ -58,7 +66,7 @@ const fakePolicyID = 'ABCD1234'; const mockPolicy = {...createRandomPolicy(0), id: fakePolicyID}; const fakeDomainAccountID = 4242; const mockDomain = {accountID: fakeDomainAccountID, validated: true, email: 'admin@example.com'}; -const mockedGetPathFromState = getPathFromState as jest.MockedFunction; +const TAB_NAV_STATE_KEY = 'tab-nav-1'; /* eslint-disable @typescript-eslint/unbound-method -- jest.fn() mocks don't rely on `this` binding */ const mockedGetRootState = navigationRef.getRootState as unknown as jest.Mock<{routes: unknown[]} | undefined>; const mockedDispatch = jest.mocked(navigationRef.dispatch); @@ -68,8 +76,6 @@ const useRestoreWorkspacesTabOnNavigate = (require('@hooks/useRestoreWorkspacesT const PolicyUtils = require('@libs/PolicyUtils') as {shouldShowPolicy: jest.Mock; isPendingDeletePolicy: jest.Mock}; -const lastVisitedTabPathUtils = require('@libs/Navigation/helpers/lastVisitedTabPathUtils') as {getWorkspacesTabStateFromSessionStorage: jest.Mock}; - function setupOnyxForPolicy() { mockUseOnyx.mockImplementation((key: unknown) => { if (key === ONYXKEYS.COLLECTION.POLICY) { @@ -94,6 +100,7 @@ function buildStateWithUserOnDifferentTab(workspaceRoutes: unknown[]) { { name: NAVIGATORS.TAB_NAVIGATOR, state: { + key: TAB_NAV_STATE_KEY, index: 0, routes: [ {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, @@ -113,17 +120,13 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { jest.clearAllMocks(); mockUseOnyx.mockReturnValue([undefined]); mockResponsiveLayout.mockReturnValue({shouldUseNarrowLayout: false}); - lastVisitedTabPathUtils.getWorkspacesTabStateFromSessionStorage.mockReturnValue(undefined); PolicyUtils.shouldShowPolicy.mockReturnValue(true); PolicyUtils.isPendingDeletePolicy.mockReturnValue(false); - mockedGetPathFromState.mockReset(); mockedGetRootState.mockReturnValue({routes: []}); }); it('restores to the last visited workspace when re-entering the Workspaces tab', () => { setupOnyxForPolicy(); - const restoredPath = `/workspaces/${fakePolicyID}` as const; - mockedGetPathFromState.mockReturnValue(restoredPath); mockedGetRootState.mockReturnValue( buildStateWithUserOnDifferentTab([ { @@ -136,7 +139,11 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); it('falls back to the workspaces list when no workspace was previously visited', () => { @@ -174,12 +181,11 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACES_LIST.route); }); - // Regression: clicking the Workspaces tab from any other tab should land the user on the *exact* sub-page - // they had open inside the workspace (e.g. Workflows), not the workspace's initial page. - it('preserves the focused workspace sub-page (Workflows) when restoring on a wide layout', () => { + // Regression: clicking the Workspaces tab from any other tab should restore the user's last + // workspace sub-page via an in-place TabActions.jumpTo against the topmost TAB_NAVIGATOR, + // not by pushing a fresh TAB_NAVIGATOR through Navigation.navigate. + it('jumps to the existing WORKSPACE_NAVIGATOR tab when restoring on a wide layout', () => { setupOnyxForPolicy(); - const restoredPath = `/workspaces/${fakePolicyID}/workflows` as const; - mockedGetPathFromState.mockReturnValue(restoredPath); mockedGetRootState.mockReturnValue( buildStateWithUserOnDifferentTab([ { @@ -198,17 +204,24 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); - // Regression for the original bug (#89106): when an RHP-driven navigation pushes a fresh TabNavigator above - // the modal, the new TabNavigator's WORKSPACE_NAVIGATOR is empty. The hook must reach into the *older* - // TabNavigator instance still alive in the root stack to recover the user's last workspace sub-page. - it('reads workspace state from an older TabNavigator instance when the topmost one is empty', () => { + // Inverted contract for the topmost-only behavior: the consumer restores via TabActions.jumpTo + // against the topmost TAB_NAVIGATOR's state key, so workspace state held by older TAB_NAVIGATOR + // instances kept alive by ensureTabNavigatorRoutes is intentionally ignored — guards against a + // regression to flat-walking all tabs. With an empty topmost workspace slot the hook still jumps + // to the topmost tab (rather than pushing a new TAB_NAVIGATOR via Navigation.navigate); cold-start + // hydration is then handled by WorkspaceRouter.getInitialState from sessionStorage. + it('jumps to the topmost TabNavigator even when its workspace slot is empty', () => { setupOnyxForPolicy(); mockedGetRootState.mockReturnValue({ routes: [ - // Older TabNavigator: still holds the workspace state with WORKFLOWS focused. + // Older TabNavigator with workspace state. Should be ignored. { name: NAVIGATORS.TAB_NAVIGATOR, state: { @@ -235,10 +248,11 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { ], }, }, - // Newer TabNavigator pushed above the modal: WORKSPACE_NAVIGATOR is empty. + // Topmost TabNavigator with empty WORKSPACE_NAVIGATOR. { name: NAVIGATORS.TAB_NAVIGATOR, state: { + key: TAB_NAV_STATE_KEY, index: 0, routes: [{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, {name: NAVIGATORS.WORKSPACE_NAVIGATOR}], }, @@ -249,22 +263,26 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - // The older TabNavigator is reached via StackActions.pop (popping the newer empty one off the root stack), - // not via a fresh Navigation.navigate. Verify dispatch was called with a POP action. - expect(mockedDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'POP'})); + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); expect(Navigation.navigate).not.toHaveBeenCalled(); }); - // On narrow layouts (mobile), the URL-based restore is skipped: we always land on the workspace's - // initial page so the user can navigate inward via the side-list — matches mobile UX and the docs. - it('falls back to the workspace initial page on narrow layouts even when a sub-page is focused', () => { + // On narrow layouts (mobile), the consumer pops the workspace split to its initial page + // *before* jumping to the tab to prevent a sub-page from flashing in. We assert both dispatches + // fire in order. TransitionTracker is mocked to invoke its callback synchronously. + it('pops the workspace split to its initial page, then jumps to the tab, on narrow layouts', () => { mockResponsiveLayout.mockReturnValue({shouldUseNarrowLayout: true}); setupOnyxForPolicy(); + const WORKSPACE_SPLIT_STATE_KEY = 'workspace-split-1'; mockedGetRootState.mockReturnValue( buildStateWithUserOnDifferentTab([ { name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, state: { + key: WORKSPACE_SPLIT_STATE_KEY, index: 1, routes: [ {name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}, @@ -278,62 +296,16 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - expect(mockedGetPathFromState).not.toHaveBeenCalled(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); - }); - - // Cold-start path: when no workspace route exists anywhere in the live nav tree, fall back to the - // sessionStorage-persisted state so a fresh page-load still restores the user's last workspace sub-page. - it('hydrates from sessionStorage when the live navigation tree has no workspace route', () => { - setupOnyxForPolicy(); - const restoredPath = `/workspaces/${fakePolicyID}/workflows` as const; - mockedGetPathFromState.mockReturnValue(restoredPath); - mockedGetRootState.mockReturnValue({ - routes: [ - { - name: NAVIGATORS.TAB_NAVIGATOR, - state: {index: 0, routes: [{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}]}, - }, - ], - }); - lastVisitedTabPathUtils.getWorkspacesTabStateFromSessionStorage.mockReturnValue({ - routes: [ - { - name: NAVIGATORS.TAB_NAVIGATOR, - state: { - routes: [ - { - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: { - routes: [ - { - name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, - state: { - index: 1, - routes: [ - {name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}, - {name: SCREENS.WORKSPACE.WORKFLOWS, params: {policyID: fakePolicyID}}, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], - }); - - const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); - result.current(); - - expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); + expect(mockedDispatch).toHaveBeenNthCalledWith(1, {...StackActions.popToTop(), target: WORKSPACE_SPLIT_STATE_KEY}); + expect(mockedDispatch).toHaveBeenNthCalledWith(2, {...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), target: TAB_NAV_STATE_KEY}); + expect(TransitionTracker.runAfterTransitions).toHaveBeenCalledWith(expect.objectContaining({waitForUpcomingTransition: true})); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); - // Domain restore: when the last workspace-tab route is a DOMAIN_SPLIT_NAVIGATOR with a domainAccountID, - // resolve the matching Domain via useOnyx(ONYXKEYS.COLLECTION.DOMAIN) and navigate to the domain page. - it('restores to the last visited domain when re-entering the Workspaces tab', () => { + // Domain restore: when the last workspace-tab route is a DOMAIN_SPLIT_NAVIGATOR with an + // accessible accountID, jump to the existing WORKSPACE_NAVIGATOR tab so the preserved + // DOMAIN_SPLIT_NAVIGATOR state is shown without pushing a new TAB_NAVIGATOR. + it('jumps to the existing WORKSPACE_NAVIGATOR tab when restoring a domain', () => { setupOnyxForDomain(); mockedGetRootState.mockReturnValue( buildStateWithUserOnDifferentTab([ @@ -347,7 +319,11 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.DOMAIN_INITIAL.getRoute(fakeDomainAccountID)); + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); // If the last route was a domain but it's no longer present in the Onyx domain collection diff --git a/tests/unit/navigateToWorkspacesPageTest.ts b/tests/unit/navigateToWorkspacesPageTest.ts index 35c369043acd..05cff0353dda 100644 --- a/tests/unit/navigateToWorkspacesPageTest.ts +++ b/tests/unit/navigateToWorkspacesPageTest.ts @@ -1,29 +1,47 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import {StackActions, TabActions} from '@react-navigation/native'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import navigateToWorkspacesPage from '@libs/Navigation/helpers/navigateToWorkspacesPage'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; +// eslint-disable-next-line no-restricted-imports -- TransitionTracker is mocked here to assert the tab-jump sequencing after popToTop in navigateToWorkspacesPage. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import * as PolicyUtils from '@libs/PolicyUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Domain} from '@src/types/onyx'; import createRandomPolicy from '../utils/collections/policies'; jest.mock('@libs/Navigation/navigationRef'); jest.mock('@libs/Navigation/Navigation'); -jest.mock('@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'); -jest.mock('@libs/PolicyUtils'); -jest.mock('@libs/interceptAnonymousUser'); -jest.mock('@libs/Navigation/helpers/getPathFromState', () => ({ +jest.mock('@libs/Navigation/TransitionTracker', () => ({ __esModule: true, - default: jest.fn(), + default: { + runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { + callback(); + return {cancel: () => {}}; + }), + startTransition: jest.fn(), + endTransition: jest.fn(), + }, })); +jest.mock('@libs/PolicyUtils'); +jest.mock('@libs/interceptAnonymousUser'); -const mockedGetPathFromState = getPathFromState as jest.MockedFunction; +// eslint-disable-next-line @typescript-eslint/unbound-method -- jest.fn() mocks don't rely on `this` binding +const mockedDispatch = jest.mocked(navigationRef.dispatch); const fakePolicyID = '344559B2CCF2B6C1'; const mockPolicy = {...createRandomPolicy(0), id: fakePolicyID}; const baseParams = {currentUserLogin: 'test@example.com', shouldUseNarrowLayout: false, policy: mockPolicy}; +const TAB_NAV_STATE_KEY = 'tab-nav-key-123'; +const tabNavigatorRoute = { + name: NAVIGATORS.TAB_NAVIGATOR, + state: {key: TAB_NAV_STATE_KEY} as PartialState, +}; + describe('navigateToWorkspacesPage', () => { beforeEach(() => { jest.clearAllMocks(); @@ -34,10 +52,11 @@ describe('navigateToWorkspacesPage', () => { callback(); }); } + it('calls goBack if WORKSPACE_NAVIGATOR is topmost and a split navigator is inside', () => { navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: { + lastTabNavigatorRoute: { name: NAVIGATORS.TAB_NAVIGATOR, state: { index: 4, @@ -48,7 +67,7 @@ describe('navigateToWorkspacesPage', () => { {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}, {name: NAVIGATORS.WORKSPACE_NAVIGATOR}, ], - }, + } as PartialState, }, lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR}, }); @@ -59,95 +78,114 @@ describe('navigateToWorkspacesPage', () => { mockIntercept(); navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + lastTabNavigatorRoute: {name: NAVIGATORS.TAB_NAVIGATOR}, lastWorkspacesTabNavigatorRoute: undefined, }); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACES_LIST.route); }); - it('navigates to the workspace initial URL when no workspacesTabState is provided', () => { + it('dispatches jumpTo WORKSPACE_NAVIGATOR when a TAB_NAVIGATOR is already on top (workspace, wide layout)', () => { (PolicyUtils.shouldShowPolicy as jest.Mock).mockReturnValue(true); (PolicyUtils.isPendingDeletePolicy as jest.Mock).mockReturnValue(false); - mockIntercept(); + navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, - lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, key: 'someKey'}, + lastTabNavigatorRoute: tabNavigatorRoute, + lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR}, }); - expect(mockedGetPathFromState).not.toHaveBeenCalled(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); - it('navigates to the URL produced by getPathFromState when workspacesTabState is provided on wide layouts', () => { + it('pops workspace split to root then jumps to tab on narrow layout when a sub-page is focused (no flicker)', () => { (PolicyUtils.shouldShowPolicy as jest.Mock).mockReturnValue(true); (PolicyUtils.isPendingDeletePolicy as jest.Mock).mockReturnValue(false); - const restoredPath = `/workspaces/${fakePolicyID}/workflows` as const; - mockedGetPathFromState.mockReturnValue(restoredPath); - mockIntercept(); - const workspacesTabState = { - index: 1, - routes: [ - {name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}, - {name: SCREENS.WORKSPACE.WORKFLOWS, params: {policyID: fakePolicyID}}, - ], - }; + + const WORKSPACE_SPLIT_STATE_KEY = 'workspace-split-state-key-456'; + navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, - lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, key: 'someKey'}, - workspacesTabState, + shouldUseNarrowLayout: true, + lastTabNavigatorRoute: tabNavigatorRoute, + lastWorkspacesTabNavigatorRoute: { + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: { + key: WORKSPACE_SPLIT_STATE_KEY, + index: 1, + routes: [{name: SCREENS.WORKSPACE.INITIAL}, {name: SCREENS.WORKSPACE.MEMBERS}], + } as PartialState, + }, }); - // Wrapped with the full TAB_NAVIGATOR > WORKSPACE_NAVIGATOR > WORKSPACE_SPLIT_NAVIGATOR ancestor chain - // so getPathFromState can match the linking-config hierarchy. - expect(mockedGetPathFromState).toHaveBeenCalledWith({ - routes: [ - { - name: NAVIGATORS.TAB_NAVIGATOR, - state: { - routes: [ - { - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: { - routes: [{name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, state: workspacesTabState}], - index: 0, - }, - }, - ], - index: 0, - }, - }, - ], - index: 0, - }); - expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); + expect(mockedDispatch).toHaveBeenNthCalledWith(1, {...StackActions.popToTop(), target: WORKSPACE_SPLIT_STATE_KEY}); + expect(mockedDispatch).toHaveBeenNthCalledWith(2, {...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), target: TAB_NAV_STATE_KEY}); + expect(TransitionTracker.runAfterTransitions).toHaveBeenCalledWith(expect.objectContaining({waitForUpcomingTransition: true})); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); - it('falls back to the workspace initial URL on narrow layouts even when workspacesTabState is provided', () => { + it('skips popToTop on narrow layout when WorkspaceInitialPage is already focused', () => { (PolicyUtils.shouldShowPolicy as jest.Mock).mockReturnValue(true); (PolicyUtils.isPendingDeletePolicy as jest.Mock).mockReturnValue(false); - mockIntercept(); + + const WORKSPACE_SPLIT_STATE_KEY = 'workspace-split-state-key-789'; + navigateToWorkspacesPage({ ...baseParams, shouldUseNarrowLayout: true, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, - lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, key: 'someKey'}, - workspacesTabState: { - index: 1, - routes: [ - {name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}, - {name: SCREENS.WORKSPACE.WORKFLOWS, params: {policyID: fakePolicyID}}, - ], + lastTabNavigatorRoute: tabNavigatorRoute, + lastWorkspacesTabNavigatorRoute: { + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: { + key: WORKSPACE_SPLIT_STATE_KEY, + index: 0, + routes: [{name: SCREENS.WORKSPACE.INITIAL}], + } as PartialState, }, }); - expect(mockedGetPathFromState).not.toHaveBeenCalled(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); + expect(mockedDispatch).toHaveBeenCalledTimes(1); + expect(mockedDispatch).toHaveBeenCalledWith({...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), target: TAB_NAV_STATE_KEY}); + expect(TransitionTracker.runAfterTransitions).not.toHaveBeenCalled(); + }); + + it('dispatches jumpTo WORKSPACE_NAVIGATOR when a TAB_NAVIGATOR is already on top (domain)', () => { + mockIntercept(); + + navigateToWorkspacesPage({ + ...baseParams, + domain: {accountID: 123} as unknown as Domain, + lastTabNavigatorRoute: tabNavigatorRoute, + lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR}, + }); + + expect(mockedDispatch).toHaveBeenCalledWith({ + ...TabActions.jumpTo(NAVIGATORS.WORKSPACE_NAVIGATOR), + target: TAB_NAV_STATE_KEY, + }); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('navigates to WORKSPACES_LIST for domain when the TAB_NAVIGATOR has no usable state key', () => { + mockIntercept(); + + navigateToWorkspacesPage({ + ...baseParams, + domain: {accountID: 123} as unknown as Domain, + // TAB_NAVIGATOR present but with no state (so no existingTabNavStateKey to jump to). + lastTabNavigatorRoute: {name: NAVIGATORS.TAB_NAVIGATOR}, + lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR}, + }); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACES_LIST.route); + expect(mockedDispatch).not.toHaveBeenCalled(); }); it('navigates to WORKSPACES_LIST if policy is pending delete', () => { @@ -157,7 +195,7 @@ describe('navigateToWorkspacesPage', () => { mockIntercept(); navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + lastTabNavigatorRoute: {name: NAVIGATORS.TAB_NAVIGATOR}, lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR}, }); @@ -171,7 +209,7 @@ describe('navigateToWorkspacesPage', () => { mockIntercept(); navigateToWorkspacesPage({ ...baseParams, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + lastTabNavigatorRoute: {name: NAVIGATORS.TAB_NAVIGATOR}, lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR}, }); @@ -183,7 +221,7 @@ describe('navigateToWorkspacesPage', () => { navigateToWorkspacesPage({ ...baseParams, policy: undefined, - topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + lastTabNavigatorRoute: {name: NAVIGATORS.TAB_NAVIGATOR}, lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR}, });