22 * @vitest -environment jsdom
33 */
44import { act } from 'react'
5- import { createRoot , hydrateRoot , type Root } from 'react-dom/client'
5+ import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
6+ import { hydrateRoot , type Root } from 'react-dom/client'
67import { renderToString } from 'react-dom/server'
78import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
8- import { STORAGE_KEYS , WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
99
10- const { mockUseWorkspacesQuery, pins } = vi . hoisted ( ( ) => ( {
11- pins : { current : new Set < string > ( ) } ,
12- mockUseWorkspacesQuery : vi . fn ( ) ,
13- } ) )
14-
15- vi . mock ( '@/hooks/queries/workspace' , ( ) => ( {
16- useWorkspacesQuery : mockUseWorkspacesQuery ,
17- EMPTY_PINNED_WORKSPACE_IDS : new Set < string > ( ) ,
18- usePinnedWorkspaceIds : ( ) => ( { data : pins . current } ) ,
19- } ) )
10+ vi . mock ( '@/lib/api/client/request' , ( ) => ( { requestJson : vi . fn ( ) } ) )
2011
2112import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces'
13+ import { workspaceKeys } from '@/hooks/queries/workspace'
14+
15+ /** In the order the server returns them: most recently visited first. */
16+ const workspaces = [
17+ { id : 'recent' , organizationId : 'org-1' } ,
18+ { id : 'other-org' , organizationId : 'org-2' } ,
19+ { id : 'earlier' , organizationId : 'org-1' } ,
20+ { id : 'unvisited' , organizationId : 'org-1' } ,
21+ ]
2222
2323function Harness ( ) {
2424 const { workspaces } = useOrganizationWorkspaces ( 'org-1' )
@@ -31,22 +31,23 @@ function Harness() {
3131 )
3232}
3333
34+ /** A client holding the list exactly as the layout prefetch hydrates it. */
35+ function seededClient ( pinnedWorkspaceIds : string [ ] ) {
36+ const queryClient = new QueryClient ( )
37+ queryClient . setQueryData ( workspaceKeys . list ( 'active' ) , {
38+ workspaces,
39+ lastActiveWorkspaceId : null ,
40+ pinnedWorkspaceIds,
41+ creationPolicy : null ,
42+ } )
43+ return queryClient
44+ }
45+
3446let container : HTMLDivElement
3547let root : Root | undefined
3648
3749beforeEach ( ( ) => {
3850 vi . stubGlobal ( 'IS_REACT_ACT_ENVIRONMENT' , true )
39- localStorage . clear ( )
40- pins . current = new Set ( )
41- mockUseWorkspacesQuery . mockReturnValue ( {
42- data : [
43- { id : 'newest' , organizationId : 'org-1' } ,
44- { id : 'other-org' , organizationId : 'org-2' } ,
45- { id : 'older' , organizationId : 'org-1' } ,
46- { id : 'oldest' , organizationId : 'org-1' } ,
47- ] ,
48- isLoading : false ,
49- } )
5051 container = document . createElement ( 'div' )
5152 document . body . appendChild ( container )
5253} )
@@ -55,69 +56,51 @@ afterEach(async () => {
5556 if ( root ) await act ( async ( ) => root ?. unmount ( ) )
5657 root = undefined
5758 container . remove ( )
58- localStorage . clear ( )
5959 vi . unstubAllGlobals ( )
6060} )
6161
6262function workspaceIds ( ) {
6363 return Array . from ( container . querySelectorAll ( 'li' ) , ( item ) => item . textContent )
6464}
6565
66- describe ( 'useOrganizationWorkspaces' , ( ) => {
67- it ( 'hydrates the prefetched order before applying visit history without changing the query cache' , async ( ) => {
68- localStorage . setItem (
69- STORAGE_KEYS . WORKSPACE_RECENCY ,
70- JSON . stringify ( { oldest : 100 , older : 200 , 'other-org' : 300 } )
66+ /** Server-renders, hydrates, and fails if hydration changed a single DOM node. */
67+ async function renderAndHydrate ( pinnedWorkspaceIds : string [ ] = [ ] ) {
68+ container . innerHTML = renderToString (
69+ < QueryClientProvider client = { seededClient ( pinnedWorkspaceIds ) } >
70+ < Harness />
71+ </ QueryClientProvider >
72+ )
73+ const serverOrder = workspaceIds ( )
74+ const onRecoverableError = vi . fn ( )
75+ const mutations : MutationRecord [ ] = [ ]
76+ const observer = new MutationObserver ( ( records ) => mutations . push ( ...records ) )
77+ observer . observe ( container , { childList : true , subtree : true , characterData : true } )
78+ await act ( async ( ) => {
79+ root = hydrateRoot (
80+ container ,
81+ < QueryClientProvider client = { seededClient ( pinnedWorkspaceIds ) } >
82+ < Harness />
83+ </ QueryClientProvider > ,
84+ { onRecoverableError }
7185 )
72- container . innerHTML = renderToString ( < Harness /> )
73- expect ( workspaceIds ( ) ) . toEqual ( [ 'newest' , 'older' , 'oldest' ] )
74-
75- const onRecoverableError = vi . fn ( )
76- await act ( async ( ) => {
77- root = hydrateRoot ( container , < Harness /> , { onRecoverableError } )
78- } )
79-
80- expect ( onRecoverableError ) . not . toHaveBeenCalled ( )
81- expect ( workspaceIds ( ) ) . toEqual ( [ 'older' , 'oldest' , 'newest' ] )
82- expect ( mockUseWorkspacesQuery ( ) . data . map ( ( { id } : { id : string } ) => id ) ) . toEqual ( [
83- 'newest' ,
84- 'other-org' ,
85- 'older' ,
86- 'oldest' ,
87- ] )
8886 } )
87+ observer . disconnect ( )
88+ expect ( onRecoverableError ) . not . toHaveBeenCalled ( )
89+ expect ( mutations ) . toEqual ( [ ] )
90+ return serverOrder
91+ }
8992
90- it ( 'preserves creation-date order when the browser has no visit history' , async ( ) => {
91- await act ( async ( ) => {
92- root = createRoot ( container )
93- root . render ( < Harness /> )
94- } )
95-
96- expect ( workspaceIds ( ) ) . toEqual ( [ 'newest' , 'older' , 'oldest' ] )
93+ describe ( 'useOrganizationWorkspaces' , ( ) => {
94+ it ( 'renders the server visit order so hydration never reshuffles rows' , async ( ) => {
95+ expect ( await renderAndHydrate ( ) ) . toEqual ( [ 'recent' , 'earlier' , 'unvisited' ] )
9796 } )
98- it ( 'keeps pins first and reacts to visits without mutating the query cache' , async ( ) => {
99- pins . current = new Set ( [ 'oldest' ] )
100- await act ( async ( ) => {
101- root = createRoot ( container )
102- root . render ( < Harness /> )
103- } )
104- expect ( workspaceIds ( ) ) . toEqual ( [ 'oldest' , 'newest' , 'older' ] )
105- await act ( async ( ) => WorkspaceRecencyStorage . touch ( 'older' ) )
106- expect ( workspaceIds ( ) ) . toEqual ( [ 'oldest' , 'older' , 'newest' ] )
107- pins . current = new Set ( )
108- await act ( async ( ) => root ?. render ( < Harness /> ) )
109- expect ( workspaceIds ( ) ) . toEqual ( [ 'older' , 'newest' , 'oldest' ] )
97+
98+ it ( 'lifts pins above the visit order' , async ( ) => {
99+ expect ( await renderAndHydrate ( [ 'unvisited' ] ) ) . toEqual ( [ 'unvisited' , 'recent' , 'earlier' ] )
110100 } )
111101
112- it ( 'follows visit history changed by another tab' , async ( ) => {
113- await act ( async ( ) => {
114- root = createRoot ( container )
115- root . render ( < Harness /> )
116- } )
117- await act ( async ( ) => {
118- localStorage . setItem ( STORAGE_KEYS . WORKSPACE_RECENCY , JSON . stringify ( { oldest : 300 } ) )
119- window . dispatchEvent ( new StorageEvent ( 'storage' , { key : STORAGE_KEYS . WORKSPACE_RECENCY } ) )
120- } )
121- expect ( workspaceIds ( ) ) . toEqual ( [ 'oldest' , 'newest' , 'older' ] )
102+ it ( 'does not reorder the cached list' , async ( ) => {
103+ await renderAndHydrate ( [ 'unvisited' ] )
104+ expect ( workspaces . map ( ( { id } ) => id ) ) . toEqual ( [ 'recent' , 'other-org' , 'earlier' , 'unvisited' ] )
122105 } )
123106} )
0 commit comments