11import { renderHook } from '@testing-library/react-native' ;
2+ import getPathFromState from '@libs/Navigation/helpers/getPathFromState' ;
23import Navigation from '@libs/Navigation/Navigation' ;
34import NAVIGATORS from '@src/NAVIGATORS' ;
45import ROUTES from '@src/ROUTES' ;
6+ import SCREENS from '@src/SCREENS' ;
57import createRandomPolicy from '../../utils/collections/policies' ;
68
79jest . mock ( '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState' , ( ) => ( {
@@ -12,7 +14,8 @@ jest.mock('@libs/Navigation/helpers/lastVisitedTabPathUtils', () => ({
1214 getWorkspacesTabStateFromSessionStorage : jest . fn ( ( ) => undefined ) ,
1315} ) ) ;
1416
15- jest . mock ( '@hooks/useResponsiveLayout' , ( ) => ( ) => ( { shouldUseNarrowLayout : false } ) ) ;
17+ const mockResponsiveLayout = jest . fn ( ( ) => ( { shouldUseNarrowLayout : false } ) ) ;
18+ jest . mock ( '@hooks/useResponsiveLayout' , ( ) => ( ) => mockResponsiveLayout ( ) ) ;
1619
1720jest . mock ( '@hooks/useCurrentUserPersonalDetails' , ( ) => ( ) => ( { login : 'test@example.com' } ) ) ;
1821
@@ -24,7 +27,11 @@ jest.mock('@hooks/useOnyx', () => (key: unknown, opts?: unknown) => mockUseOnyx(
2427
2528jest . mock ( '@libs/interceptAnonymousUser' , ( ) => ( cb : ( ) => void ) => cb ( ) ) ;
2629
27- jest . mock ( '@libs/Navigation/navigationRef' , ( ) => ( { getRootState : jest . fn ( ( ) => ( { routes : [ ] } ) ) , isReady : jest . fn ( ( ) => true ) } ) ) ;
30+ jest . mock ( '@libs/Navigation/navigationRef' , ( ) => ( {
31+ getRootState : jest . fn ( ( ) => ( { routes : [ ] } ) ) ,
32+ isReady : jest . fn ( ( ) => true ) ,
33+ dispatch : jest . fn ( ) ,
34+ } ) ) ;
2835
2936jest . mock ( '@react-navigation/native' , ( ) => ( {
3037 findFocusedRoute : jest . fn ( ( ) => ( { name : 'some-screen' } ) ) ,
@@ -35,6 +42,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({
3542 goBack : jest . fn ( ) ,
3643} ) ) ;
3744
45+ jest . mock ( '@libs/Navigation/helpers/getPathFromState' , ( ) => ( {
46+ __esModule : true ,
47+ default : jest . fn ( ) ,
48+ } ) ) ;
49+
3850// eslint-disable-next-line no-restricted-syntax
3951jest . mock ( '@libs/PolicyUtils' , ( ) => ( {
4052 shouldShowPolicy : jest . fn ( ( ) => true ) ,
@@ -43,13 +55,17 @@ jest.mock('@libs/PolicyUtils', () => ({
4355
4456const fakePolicyID = 'ABCD1234' ;
4557const mockPolicy = { ...createRandomPolicy ( 0 ) , id : fakePolicyID } ;
58+ const mockedGetPathFromState = getPathFromState as jest . MockedFunction < typeof getPathFromState > ;
4659
4760// eslint-disable-next-line @typescript-eslint/no-require-imports
4861const useRestoreWorkspacesTabOnNavigate = ( require ( '@hooks/useRestoreWorkspacesTabOnNavigate' ) as { default : ( ) => ( ) => void } ) . default ;
4962
5063// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
5164const PolicyUtils = require ( '@libs/PolicyUtils' ) as { shouldShowPolicy : jest . Mock ; isPendingDeletePolicy : jest . Mock } ;
5265
66+ // eslint-disable-next-line @typescript-eslint/no-require-imports
67+ const lastVisitedTabPathUtils = require ( '@libs/Navigation/helpers/lastVisitedTabPathUtils' ) as { getWorkspacesTabStateFromSessionStorage : jest . Mock } ;
68+
5369function setupOnyxForPolicy ( ) {
5470 mockUseOnyx . mockImplementation ( ( _key : unknown , opts ?: { selector ?: ( data : unknown ) => unknown } ) => {
5571 if ( opts ?. selector ) {
@@ -83,18 +99,30 @@ describe('useRestoreWorkspacesTabOnNavigate', () => {
8399 beforeEach ( ( ) => {
84100 jest . clearAllMocks ( ) ;
85101 mockUseOnyx . mockReturnValue ( [ undefined ] ) ;
102+ mockResponsiveLayout . mockReturnValue ( { shouldUseNarrowLayout : false } ) ;
103+ lastVisitedTabPathUtils . getWorkspacesTabStateFromSessionStorage . mockReturnValue ( undefined ) ;
86104 PolicyUtils . shouldShowPolicy . mockReturnValue ( true ) ;
87105 PolicyUtils . isPendingDeletePolicy . mockReturnValue ( false ) ;
106+ mockedGetPathFromState . mockReset ( ) ;
88107 } ) ;
89108
90109 it ( 'restores to the last visited workspace when re-entering the Workspaces tab' , ( ) => {
91110 setupOnyxForPolicy ( ) ;
92- mockRootState . mockReturnValue ( buildStateWithUserOnDifferentTab ( [ { name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR , state : { routes : [ { params : { policyID : fakePolicyID } } ] } } ] ) ) ;
111+ const restoredPath = `/workspaces/${ fakePolicyID } ` as const ;
112+ mockedGetPathFromState . mockReturnValue ( restoredPath ) ;
113+ mockRootState . mockReturnValue (
114+ buildStateWithUserOnDifferentTab ( [
115+ {
116+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
117+ state : { routes : [ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ] } ,
118+ } ,
119+ ] ) ,
120+ ) ;
93121
94122 const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
95123 result . current ( ) ;
96124
97- expect ( Navigation . navigate ) . toHaveBeenCalledWith ( ROUTES . WORKSPACE_INITIAL . getRoute ( fakePolicyID ) ) ;
125+ expect ( Navigation . navigate ) . toHaveBeenCalledWith ( restoredPath ) ;
98126 } ) ;
99127
100128 it ( 'falls back to the workspaces list when no workspace was previously visited' , ( ) => {
@@ -117,11 +145,167 @@ describe('useRestoreWorkspacesTabOnNavigate', () => {
117145 PolicyUtils . isPendingDeletePolicy . mockReturnValue ( true ) ;
118146
119147 setupOnyxForPolicy ( ) ;
120- mockRootState . mockReturnValue ( buildStateWithUserOnDifferentTab ( [ { name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR , state : { routes : [ { params : { policyID : fakePolicyID } } ] } } ] ) ) ;
148+ mockRootState . mockReturnValue (
149+ buildStateWithUserOnDifferentTab ( [
150+ {
151+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
152+ state : { routes : [ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ] } ,
153+ } ,
154+ ] ) ,
155+ ) ;
121156
122157 const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
123158 result . current ( ) ;
124159
125160 expect ( Navigation . navigate ) . toHaveBeenCalledWith ( ROUTES . WORKSPACES_LIST . route ) ;
126161 } ) ;
162+
163+ // Regression: clicking the Workspaces tab from any other tab should land the user on the *exact* sub-page
164+ // they had open inside the workspace (e.g. Workflows), not the workspace's initial page.
165+ it ( 'preserves the focused workspace sub-page (Workflows) when restoring on a wide layout' , ( ) => {
166+ setupOnyxForPolicy ( ) ;
167+ const restoredPath = `/workspaces/${ fakePolicyID } /workflows` as const ;
168+ mockedGetPathFromState . mockReturnValue ( restoredPath ) ;
169+ mockRootState . mockReturnValue (
170+ buildStateWithUserOnDifferentTab ( [
171+ {
172+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
173+ state : {
174+ index : 1 ,
175+ routes : [
176+ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ,
177+ { name : SCREENS . WORKSPACE . WORKFLOWS , params : { policyID : fakePolicyID } } ,
178+ ] ,
179+ } ,
180+ } ,
181+ ] ) ,
182+ ) ;
183+
184+ const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
185+ result . current ( ) ;
186+
187+ expect ( Navigation . navigate ) . toHaveBeenCalledWith ( restoredPath ) ;
188+ } ) ;
189+
190+ // Regression for the original bug (#89106): when an RHP-driven navigation pushes a fresh TabNavigator above
191+ // the modal, the new TabNavigator's WORKSPACE_NAVIGATOR is empty. The hook must reach into the *older*
192+ // TabNavigator instance still alive in the root stack to recover the user's last workspace sub-page.
193+ it ( 'reads workspace state from an older TabNavigator instance when the topmost one is empty' , ( ) => {
194+ setupOnyxForPolicy ( ) ;
195+ const restoredPath = `/workspaces/${ fakePolicyID } /workflows` as const ;
196+ mockedGetPathFromState . mockReturnValue ( restoredPath ) ;
197+ mockRootState . mockReturnValue ( {
198+ routes : [
199+ // Older TabNavigator: still holds the workspace state with WORKFLOWS focused.
200+ {
201+ name : NAVIGATORS . TAB_NAVIGATOR ,
202+ state : {
203+ index : 1 ,
204+ routes : [
205+ { name : NAVIGATORS . REPORTS_SPLIT_NAVIGATOR } ,
206+ {
207+ name : NAVIGATORS . WORKSPACE_NAVIGATOR ,
208+ state : {
209+ routes : [
210+ {
211+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
212+ state : {
213+ index : 1 ,
214+ routes : [
215+ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ,
216+ { name : SCREENS . WORKSPACE . WORKFLOWS , params : { policyID : fakePolicyID } } ,
217+ ] ,
218+ } ,
219+ } ,
220+ ] ,
221+ } ,
222+ } ,
223+ ] ,
224+ } ,
225+ } ,
226+ // Newer TabNavigator pushed above the modal: WORKSPACE_NAVIGATOR is empty.
227+ {
228+ name : NAVIGATORS . TAB_NAVIGATOR ,
229+ state : {
230+ index : 0 ,
231+ routes : [ { name : NAVIGATORS . REPORTS_SPLIT_NAVIGATOR } , { name : NAVIGATORS . WORKSPACE_NAVIGATOR } ] ,
232+ } ,
233+ } ,
234+ ] ,
235+ } ) ;
236+
237+ const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
238+ result . current ( ) ;
239+
240+ expect ( Navigation . navigate ) . toHaveBeenCalledWith ( restoredPath ) ;
241+ } ) ;
242+
243+ // On narrow layouts (mobile), the URL-based restore is skipped: we always land on the workspace's
244+ // initial page so the user can navigate inward via the side-list — matches mobile UX and the docs.
245+ it ( 'falls back to the workspace initial page on narrow layouts even when a sub-page is focused' , ( ) => {
246+ mockResponsiveLayout . mockReturnValue ( { shouldUseNarrowLayout : true } ) ;
247+ setupOnyxForPolicy ( ) ;
248+ mockRootState . mockReturnValue (
249+ buildStateWithUserOnDifferentTab ( [
250+ {
251+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
252+ state : {
253+ index : 1 ,
254+ routes : [
255+ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ,
256+ { name : SCREENS . WORKSPACE . WORKFLOWS , params : { policyID : fakePolicyID } } ,
257+ ] ,
258+ } ,
259+ } ,
260+ ] ) ,
261+ ) ;
262+
263+ const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
264+ result . current ( ) ;
265+
266+ expect ( mockedGetPathFromState ) . not . toHaveBeenCalled ( ) ;
267+ expect ( Navigation . navigate ) . toHaveBeenCalledWith ( ROUTES . WORKSPACE_INITIAL . getRoute ( fakePolicyID ) ) ;
268+ } ) ;
269+
270+ // Cold-start path: when no workspace route exists anywhere in the live nav tree, fall back to the
271+ // sessionStorage-persisted state so a fresh page-load still restores the user's last workspace sub-page.
272+ it ( 'hydrates from sessionStorage when the live navigation tree has no workspace route' , ( ) => {
273+ setupOnyxForPolicy ( ) ;
274+ const restoredPath = `/workspaces/${ fakePolicyID } /workflows` as const ;
275+ mockedGetPathFromState . mockReturnValue ( restoredPath ) ;
276+ mockRootState . mockReturnValue ( {
277+ routes : [
278+ {
279+ name : NAVIGATORS . TAB_NAVIGATOR ,
280+ state : { index : 0 , routes : [ { name : NAVIGATORS . REPORTS_SPLIT_NAVIGATOR } ] } ,
281+ } ,
282+ ] ,
283+ } ) ;
284+ lastVisitedTabPathUtils . getWorkspacesTabStateFromSessionStorage . mockReturnValue ( {
285+ routes : [
286+ {
287+ name : NAVIGATORS . WORKSPACE_NAVIGATOR ,
288+ state : {
289+ routes : [
290+ {
291+ name : NAVIGATORS . WORKSPACE_SPLIT_NAVIGATOR ,
292+ state : {
293+ index : 1 ,
294+ routes : [
295+ { name : SCREENS . WORKSPACE . INITIAL , params : { policyID : fakePolicyID } } ,
296+ { name : SCREENS . WORKSPACE . WORKFLOWS , params : { policyID : fakePolicyID } } ,
297+ ] ,
298+ } ,
299+ } ,
300+ ] ,
301+ } ,
302+ } ,
303+ ] ,
304+ } ) ;
305+
306+ const { result} = renderHook ( ( ) => useRestoreWorkspacesTabOnNavigate ( ) ) ;
307+ result . current ( ) ;
308+
309+ expect ( Navigation . navigate ) . toHaveBeenCalledWith ( restoredPath ) ;
310+ } ) ;
127311} ) ;
0 commit comments