From 314bc8854debe4dbf981f668d0539a0087420770 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 28 Apr 2026 15:31:44 +0200 Subject: [PATCH 1/4] Fix Workspaces tab reset by scanning all TabNavigators --- .../useRestoreWorkspacesTabOnNavigate.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts index 3b5c4ef72c9d..73adf9310168 100644 --- a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts +++ b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts @@ -35,22 +35,26 @@ function useRestoreWorkspacesTabOnNavigate() { return {}; } - // Look inside TabNavigator for WORKSPACE_NAVIGATOR - const rootTabRoute = rootState?.routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); - const rootTabState = getTabState(rootTabRoute); - const workspaceNavigatorRoute = rootTabState?.routes?.find((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR); + // 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 (workspaceNavigatorRoute) { - const workspaceNavigatorState = workspaceNavigatorRoute.state ?? (workspaceNavigatorRoute.key ? getPreservedNavigatorState(workspaceNavigatorRoute.key) : undefined); - const lastWorkspaceRoute = workspaceNavigatorState?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); - if (lastWorkspaceRoute) { - const tabState = lastWorkspaceRoute.state ?? (lastWorkspaceRoute.key ? getPreservedNavigatorState(lastWorkspaceRoute.key) : undefined); - return {lastWorkspacesTabNavigatorRoute: lastWorkspaceRoute, workspacesTabState: tabState, topmostFullScreenRoute}; - } - return {topmostFullScreenRoute}; + 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 route exists in the navigation tree + // Fall back to session storage when no workspace route exists anywhere in the navigation tree. const sessionRoute = getWorkspacesTabStateFromSessionStorage() ?.routes?.findLast((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR) ?.state?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); From 7e184c0ea7d9c73dee1e164d3aa2ee886e869dc4 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 29 Apr 2026 16:34:51 +0200 Subject: [PATCH 2/4] Restore workspace sub-page on tab re-entry via URL navigation --- .../useRestoreWorkspacesTabOnNavigate.ts | 3 +- .../helpers/navigateToWorkspacesPage.ts | 45 +++- .../useRestoreWorkspacesTabOnNavigate.test.ts | 194 +++++++++++++++++- tests/unit/navigateToWorkspacesPageTest.ts | 80 +++++++- 4 files changed, 310 insertions(+), 12 deletions(-) diff --git a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts index 73adf9310168..08806d774f70 100644 --- a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts +++ b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts @@ -105,8 +105,9 @@ function useRestoreWorkspacesTabOnNavigate() { domain: lastViewedDomain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute, + workspacesTabState, }); - }, [shouldUseNarrowLayout, currentUserLogin, lastViewedPolicy, lastViewedDomain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute]); + }, [shouldUseNarrowLayout, currentUserLogin, lastViewedPolicy, lastViewedDomain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute, workspacesTabState]); } export default useRestoreWorkspacesTabOnNavigate; diff --git a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts index 90c914847eff..200ae70b9c65 100644 --- a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts +++ b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts @@ -6,12 +6,23 @@ import navigationRef from '@libs/Navigation/navigationRef'; 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'; 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); +} + type Params = { currentUserLogin?: string; shouldUseNarrowLayout: boolean; @@ -19,11 +30,18 @@ type Params = { 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; }; -// Navigates to the appropriate workspace tab or workspace list page. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- shouldUseNarrowLayout kept for API compat with callers -const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, policy, domain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute}: Params) => { +const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, policy, domain, lastWorkspacesTabNavigatorRoute, topmostFullScreenRoute, workspacesTabState}: Params) => { const rootState = navigationRef.getRootState(); const focusedRoute = rootState ? findFocusedRoute(rootState) : undefined; const isOnWorkspacesList = focusedRoute?.name === SCREENS.WORKSPACES_LIST; @@ -61,9 +79,26 @@ const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, poli return; } - // Restore to last-visited workspace — navigate through standard routing which switches the tab if (policy?.id) { - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(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); } return; } diff --git a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts index 38bf44e348e8..b70e014ce389 100644 --- a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts +++ b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts @@ -1,7 +1,9 @@ import {renderHook} from '@testing-library/react-native'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import Navigation from '@libs/Navigation/Navigation'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import createRandomPolicy from '../../utils/collections/policies'; jest.mock('@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState', () => ({ @@ -12,7 +14,8 @@ jest.mock('@libs/Navigation/helpers/lastVisitedTabPathUtils', () => ({ getWorkspacesTabStateFromSessionStorage: jest.fn(() => undefined), })); -jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: false})); +const mockResponsiveLayout = jest.fn(() => ({shouldUseNarrowLayout: false})); +jest.mock('@hooks/useResponsiveLayout', () => () => mockResponsiveLayout()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({login: 'test@example.com'})); @@ -24,7 +27,11 @@ jest.mock('@hooks/useOnyx', () => (key: unknown, opts?: unknown) => mockUseOnyx( jest.mock('@libs/interceptAnonymousUser', () => (cb: () => void) => cb()); -jest.mock('@libs/Navigation/navigationRef', () => ({getRootState: jest.fn(() => ({routes: []})), isReady: jest.fn(() => true)})); +jest.mock('@libs/Navigation/navigationRef', () => ({ + getRootState: jest.fn(() => ({routes: []})), + isReady: jest.fn(() => true), + dispatch: jest.fn(), +})); jest.mock('@react-navigation/native', () => ({ findFocusedRoute: jest.fn(() => ({name: 'some-screen'})), @@ -35,6 +42,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({ goBack: jest.fn(), })); +jest.mock('@libs/Navigation/helpers/getPathFromState', () => ({ + __esModule: true, + default: jest.fn(), +})); + // eslint-disable-next-line no-restricted-syntax jest.mock('@libs/PolicyUtils', () => ({ shouldShowPolicy: jest.fn(() => true), @@ -43,6 +55,7 @@ jest.mock('@libs/PolicyUtils', () => ({ const fakePolicyID = 'ABCD1234'; const mockPolicy = {...createRandomPolicy(0), id: fakePolicyID}; +const mockedGetPathFromState = getPathFromState as jest.MockedFunction; // eslint-disable-next-line @typescript-eslint/no-require-imports const useRestoreWorkspacesTabOnNavigate = (require('@hooks/useRestoreWorkspacesTabOnNavigate') as {default: () => () => void}).default; @@ -50,6 +63,9 @@ const useRestoreWorkspacesTabOnNavigate = (require('@hooks/useRestoreWorkspacesT // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax const PolicyUtils = require('@libs/PolicyUtils') as {shouldShowPolicy: jest.Mock; isPendingDeletePolicy: jest.Mock}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const lastVisitedTabPathUtils = require('@libs/Navigation/helpers/lastVisitedTabPathUtils') as {getWorkspacesTabStateFromSessionStorage: jest.Mock}; + function setupOnyxForPolicy() { mockUseOnyx.mockImplementation((_key: unknown, opts?: {selector?: (data: unknown) => unknown}) => { if (opts?.selector) { @@ -83,18 +99,30 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { beforeEach(() => { jest.clearAllMocks(); mockUseOnyx.mockReturnValue([undefined]); + mockResponsiveLayout.mockReturnValue({shouldUseNarrowLayout: false}); + lastVisitedTabPathUtils.getWorkspacesTabStateFromSessionStorage.mockReturnValue(undefined); PolicyUtils.shouldShowPolicy.mockReturnValue(true); PolicyUtils.isPendingDeletePolicy.mockReturnValue(false); + mockedGetPathFromState.mockReset(); }); it('restores to the last visited workspace when re-entering the Workspaces tab', () => { setupOnyxForPolicy(); - mockRootState.mockReturnValue(buildStateWithUserOnDifferentTab([{name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, state: {routes: [{params: {policyID: fakePolicyID}}]}}])); + const restoredPath = `/workspaces/${fakePolicyID}` as const; + mockedGetPathFromState.mockReturnValue(restoredPath); + mockRootState.mockReturnValue( + buildStateWithUserOnDifferentTab([ + { + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: {routes: [{name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}]}, + }, + ]), + ); const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); + expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); }); it('falls back to the workspaces list when no workspace was previously visited', () => { @@ -117,11 +145,167 @@ describe('useRestoreWorkspacesTabOnNavigate', () => { PolicyUtils.isPendingDeletePolicy.mockReturnValue(true); setupOnyxForPolicy(); - mockRootState.mockReturnValue(buildStateWithUserOnDifferentTab([{name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, state: {routes: [{params: {policyID: fakePolicyID}}]}}])); + mockRootState.mockReturnValue( + buildStateWithUserOnDifferentTab([ + { + name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + state: {routes: [{name: SCREENS.WORKSPACE.INITIAL, params: {policyID: fakePolicyID}}]}, + }, + ]), + ); const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); result.current(); 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', () => { + setupOnyxForPolicy(); + const restoredPath = `/workspaces/${fakePolicyID}/workflows` as const; + mockedGetPathFromState.mockReturnValue(restoredPath); + mockRootState.mockReturnValue( + buildStateWithUserOnDifferentTab([ + { + 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); + }); + + // 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', () => { + setupOnyxForPolicy(); + const restoredPath = `/workspaces/${fakePolicyID}/workflows` as const; + mockedGetPathFromState.mockReturnValue(restoredPath); + mockRootState.mockReturnValue({ + routes: [ + // Older TabNavigator: still holds the workspace state with WORKFLOWS focused. + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 1, + routes: [ + {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + { + 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}}, + ], + }, + }, + ], + }, + }, + ], + }, + }, + // Newer TabNavigator pushed above the modal: WORKSPACE_NAVIGATOR is empty. + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 0, + routes: [{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, {name: NAVIGATORS.WORKSPACE_NAVIGATOR}], + }, + }, + ], + }); + + const {result} = renderHook(() => useRestoreWorkspacesTabOnNavigate()); + result.current(); + + expect(Navigation.navigate).toHaveBeenCalledWith(restoredPath); + }); + + // 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', () => { + mockResponsiveLayout.mockReturnValue({shouldUseNarrowLayout: true}); + setupOnyxForPolicy(); + mockRootState.mockReturnValue( + buildStateWithUserOnDifferentTab([ + { + 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(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); + mockRootState.mockReturnValue({ + routes: [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: {index: 0, routes: [{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}]}, + }, + ], + }); + lastVisitedTabPathUtils.getWorkspacesTabStateFromSessionStorage.mockReturnValue({ + 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); + }); }); diff --git a/tests/unit/navigateToWorkspacesPageTest.ts b/tests/unit/navigateToWorkspacesPageTest.ts index f92f51e573ed..1a5a85d1429a 100644 --- a/tests/unit/navigateToWorkspacesPageTest.ts +++ b/tests/unit/navigateToWorkspacesPageTest.ts @@ -1,10 +1,12 @@ 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'; // eslint-disable-next-line no-restricted-syntax import * as PolicyUtils from '@libs/PolicyUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import createRandomPolicy from '../utils/collections/policies'; jest.mock('@libs/Navigation/navigationRef'); @@ -12,6 +14,12 @@ 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', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockedGetPathFromState = getPathFromState as jest.MockedFunction; const fakePolicyID = '344559B2CCF2B6C1'; const mockPolicy = {...createRandomPolicy(0), id: fakePolicyID}; @@ -59,7 +67,7 @@ describe('navigateToWorkspacesPage', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACES_LIST.route); }); - it('navigates to workspace initial screen if valid policy and screen exist', () => { + it('navigates to the workspace initial URL when no workspacesTabState is provided', () => { (PolicyUtils.shouldShowPolicy as jest.Mock).mockReturnValue(true); (PolicyUtils.isPendingDeletePolicy as jest.Mock).mockReturnValue(false); @@ -70,6 +78,76 @@ describe('navigateToWorkspacesPage', () => { lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, key: 'someKey'}, }); + expect(mockedGetPathFromState).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); + }); + + it('navigates to the URL produced by getPathFromState when workspacesTabState is provided on wide layouts', () => { + (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}}, + ], + }; + navigateToWorkspacesPage({ + ...baseParams, + topmostFullScreenRoute: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + lastWorkspacesTabNavigatorRoute: {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, key: 'someKey'}, + workspacesTabState, + }); + + // 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); + }); + + it('falls back to the workspace initial URL on narrow layouts even when workspacesTabState is provided', () => { + (PolicyUtils.shouldShowPolicy as jest.Mock).mockReturnValue(true); + (PolicyUtils.isPendingDeletePolicy as jest.Mock).mockReturnValue(false); + + mockIntercept(); + 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}}, + ], + }, + }); + + expect(mockedGetPathFromState).not.toHaveBeenCalled(); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(fakePolicyID)); }); From 6f14a9bc88772bb1d348a543f773331ca797a280 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 29 Apr 2026 18:32:17 +0200 Subject: [PATCH 3/4] Add eslint-disable comment for __esModule naming convention in test mocks --- tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts | 1 + tests/unit/navigateToWorkspacesPageTest.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts index b70e014ce389..490b756b0f76 100644 --- a/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts +++ b/tests/unit/hooks/useRestoreWorkspacesTabOnNavigate.test.ts @@ -43,6 +43,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); jest.mock('@libs/Navigation/helpers/getPathFromState', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: jest.fn(), })); diff --git a/tests/unit/navigateToWorkspacesPageTest.ts b/tests/unit/navigateToWorkspacesPageTest.ts index 1a5a85d1429a..991c4bb4c6c2 100644 --- a/tests/unit/navigateToWorkspacesPageTest.ts +++ b/tests/unit/navigateToWorkspacesPageTest.ts @@ -15,6 +15,7 @@ jest.mock('@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigat jest.mock('@libs/PolicyUtils'); jest.mock('@libs/interceptAnonymousUser'); jest.mock('@libs/Navigation/helpers/getPathFromState', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: jest.fn(), })); From 92109257a98f030ea84b1ddbfc0df00e7461b49a Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 29 Apr 2026 18:40:09 +0200 Subject: [PATCH 4/4] Fix session-storage fallback to include topmostFullScreenRoute --- src/hooks/useRestoreWorkspacesTabOnNavigate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts index 08806d774f70..0b80ca8b3dd2 100644 --- a/src/hooks/useRestoreWorkspacesTabOnNavigate.ts +++ b/src/hooks/useRestoreWorkspacesTabOnNavigate.ts @@ -59,7 +59,7 @@ function useRestoreWorkspacesTabOnNavigate() { ?.routes?.findLast((route) => route.name === NAVIGATORS.WORKSPACE_NAVIGATOR) ?.state?.routes?.findLast((route) => isWorkspaceNavigatorRouteName(route.name)); if (sessionRoute) { - return {lastWorkspacesTabNavigatorRoute: sessionRoute, workspacesTabState: sessionRoute.state}; + return {lastWorkspacesTabNavigatorRoute: sessionRoute, workspacesTabState: sessionRoute.state, topmostFullScreenRoute}; } return {topmostFullScreenRoute};