Skip to content

Commit eb8fec7

Browse files
committed
fix(sidebar): render workspace recency order on the server to stop refresh reshuffle
1 parent d8e7e92 commit eb8fec7

8 files changed

Lines changed: 182 additions & 19 deletions

File tree

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { act } from 'react'
55
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
66
import { renderToString } from 'react-dom/server'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider'
89
import { STORAGE_KEYS, WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
10+
import { WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie'
911

1012
const { mockUseWorkspacesQuery, pins } = vi.hoisted(() => ({
1113
pins: { current: new Set<string>() },
@@ -64,20 +66,31 @@ function workspaceIds() {
6466
}
6567

6668
describe('useOrganizationWorkspaces', () => {
67-
it('hydrates the prefetched order before applying visit history without changing the query cache', async () => {
69+
it('renders the visit order on the server so hydration does not reshuffle rows', async () => {
6870
localStorage.setItem(
6971
STORAGE_KEYS.WORKSPACE_RECENCY,
7072
JSON.stringify({ oldest: 100, older: 200, 'other-org': 300 })
7173
)
72-
container.innerHTML = renderToString(<Harness />)
73-
expect(workspaceIds()).toEqual(['newest', 'older', 'oldest'])
74+
const recentIds = ['other-org', 'older', 'oldest']
75+
const tree = (
76+
<WorkspaceRecencyProvider recentWorkspaceIds={recentIds}>
77+
<Harness />
78+
</WorkspaceRecencyProvider>
79+
)
80+
container.innerHTML = renderToString(tree)
81+
expect(workspaceIds()).toEqual(['older', 'oldest', 'newest'])
7482

7583
const onRecoverableError = vi.fn()
84+
const observed: string[][] = []
85+
const observer = new MutationObserver(() => observed.push(workspaceIds()))
86+
observer.observe(container, { childList: true, subtree: true, characterData: true })
7687
await act(async () => {
77-
root = hydrateRoot(container, <Harness />, { onRecoverableError })
88+
root = hydrateRoot(container, tree, { onRecoverableError })
7889
})
90+
observer.disconnect()
7991

8092
expect(onRecoverableError).not.toHaveBeenCalled()
93+
expect(observed).toEqual([])
8194
expect(workspaceIds()).toEqual(['older', 'oldest', 'newest'])
8295
expect(mockUseWorkspacesQuery().data.map(({ id }: { id: string }) => id)).toEqual([
8396
'newest',
@@ -87,6 +100,15 @@ describe('useOrganizationWorkspaces', () => {
87100
])
88101
})
89102

103+
it('mirrors visits into the recency cookie', async () => {
104+
await act(async () => {
105+
root = createRoot(container)
106+
root.render(<Harness />)
107+
})
108+
await act(async () => WorkspaceRecencyStorage.touch('older'))
109+
expect(document.cookie).toContain(`${WORKSPACE_RECENCY_COOKIE}=older`)
110+
})
111+
90112
it('preserves creation-date order when the browser has no visit history', async () => {
91113
await act(async () => {
92114
root = createRoot(container)

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
4+
import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider'
45
import { getSession } from '@/lib/auth'
56
import { getActiveOrganizationId } from '@/lib/auth/session-response'
67
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
78
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
89
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
910
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
11+
import { parseWorkspaceRecency, WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie'
1012
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1113
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1214
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
@@ -66,6 +68,7 @@ export default async function OrganizationLayout({
6668
isPlanModeEnabled(),
6769
])
6870
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
71+
const recentWorkspaceIds = parseWorkspaceRecency(cookieStore.get(WORKSPACE_RECENCY_COOKIE)?.value)
6972

7073
return (
7174
<HydrationBoundary state={dehydrate(queryClient)}>
@@ -81,12 +84,14 @@ export default async function OrganizationLayout({
8184
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
8285
<ImpersonationBanner />
8386
<SessionExpired />
84-
<WorkspaceChrome
85-
sidebar={<OrganizationSidebar />}
86-
initialSidebarCollapsed={initialSidebarCollapsed}
87-
>
88-
{children}
89-
</WorkspaceChrome>
87+
<WorkspaceRecencyProvider recentWorkspaceIds={recentWorkspaceIds}>
88+
<WorkspaceChrome
89+
sidebar={<OrganizationSidebar />}
90+
initialSidebarCollapsed={initialSidebarCollapsed}
91+
>
92+
{children}
93+
</WorkspaceChrome>
94+
</WorkspaceRecencyProvider>
9095
</div>
9196
</GlobalCommandsProvider>
9297
</OrganizationProvider>

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
4+
import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider'
45
import { getSession } from '@/lib/auth'
56
import { getActiveOrganizationId } from '@/lib/auth/session-response'
67
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
78
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
89
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
10+
import { parseWorkspaceRecency, WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie'
911
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1012
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
1113
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
@@ -81,6 +83,7 @@ export default async function WorkspaceLayout({
8183
}),
8284
])
8385
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
86+
const recentWorkspaceIds = parseWorkspaceRecency(cookieStore.get(WORKSPACE_RECENCY_COOKIE)?.value)
8487

8588
return (
8689
<HydrationBoundary state={dehydrate(queryClient)}>
@@ -108,12 +111,14 @@ export default async function WorkspaceLayout({
108111
<SessionExpired />
109112
<WorkspacePermissionsProvider>
110113
<WorkspaceScopeSync />
111-
<WorkspaceChrome
112-
sidebar={<Sidebar organizationHref={organizationHref} />}
113-
initialSidebarCollapsed={initialSidebarCollapsed}
114-
>
115-
{children}
116-
</WorkspaceChrome>
114+
<WorkspaceRecencyProvider recentWorkspaceIds={recentWorkspaceIds}>
115+
<WorkspaceChrome
116+
sidebar={<Sidebar organizationHref={organizationHref} />}
117+
initialSidebarCollapsed={initialSidebarCollapsed}
118+
>
119+
{children}
120+
</WorkspaceChrome>
121+
</WorkspaceRecencyProvider>
117122
</WorkspacePermissionsProvider>
118123
</div>
119124
</GlobalCommandsProvider>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client'
2+
3+
import { createContext, useContext } from 'react'
4+
5+
const EMPTY_RECENT_WORKSPACE_IDS: readonly string[] = []
6+
7+
const WorkspaceRecencyContext = createContext<readonly string[]>(EMPTY_RECENT_WORKSPACE_IDS)
8+
9+
interface WorkspaceRecencyProviderProps {
10+
/** Most-recent-first workspace ids read from the recency cookie by the layout. */
11+
recentWorkspaceIds: readonly string[]
12+
children: React.ReactNode
13+
}
14+
15+
/**
16+
* Hands the server-read visit order to the workspace lists so server render and
17+
* hydration agree with the order the browser's visit history applies after it.
18+
*/
19+
export function WorkspaceRecencyProvider({
20+
recentWorkspaceIds,
21+
children,
22+
}: WorkspaceRecencyProviderProps) {
23+
return (
24+
<WorkspaceRecencyContext.Provider value={recentWorkspaceIds}>
25+
{children}
26+
</WorkspaceRecencyContext.Provider>
27+
)
28+
}
29+
30+
export function useInitialRecentWorkspaceIds(): readonly string[] {
31+
return useContext(WorkspaceRecencyContext)
32+
}
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
'use client'
22

33
import { useMemo, useSyncExternalStore } from 'react'
4+
import { useInitialRecentWorkspaceIds } from '@/components/workspaces/workspace-recency-provider'
45
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
6+
import { sortByRecentIds } from '@/lib/workspaces/recency-cookie'
57
import type { Workspace } from '@/hooks/queries/workspace'
68

79
const serverSnapshot = () => null
810

9-
/** Layers the viewer's pins and visit history over the server's newest-first list. */
11+
/**
12+
* Layers the viewer's pins and visit history over the server's newest-first list.
13+
* Server render and hydration order by the recency cookie, which mirrors the head
14+
* of the localStorage history, so switching to that history after hydration does
15+
* not reshuffle the rows.
16+
*/
1017
export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySet<string>) {
18+
const initialRecentIds = useInitialRecentWorkspaceIds()
1119
const recencySnapshot = useSyncExternalStore(
1220
WorkspaceRecencyStorage.subscribe,
1321
WorkspaceRecencyStorage.getSnapshot,
@@ -16,7 +24,7 @@ export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySe
1624
return useMemo(() => {
1725
const byRecency = recencySnapshot
1826
? WorkspaceRecencyStorage.sortByRecency(workspaces)
19-
: workspaces
27+
: sortByRecentIds(workspaces, initialRecentIds)
2028
return [...byRecency].sort((a, b) => Number(pinnedIds.has(b.id)) - Number(pinnedIds.has(a.id)))
21-
}, [workspaces, pinnedIds, recencySnapshot])
29+
}, [workspaces, pinnedIds, recencySnapshot, initialRecentIds])
2230
}

‎apps/sim/lib/core/utils/browser-storage.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import {
33
workspaceSearchFiltersSchema,
44
} from '@/lib/api/contracts/knowledge/search'
55
import { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
6+
import {
7+
serializeWorkspaceRecency,
8+
WORKSPACE_RECENCY_COOKIE,
9+
} from '@/lib/workspaces/recency-cookie'
610
/**
711
* Safe localStorage utilities with SSR support
812
* Provides clean error handling and type safety for browser storage operations
@@ -141,8 +145,10 @@ export class WorkspaceRecencyStorage {
141145
}
142146
}
143147

148+
/** Also mirrors the head of the history into a cookie so server renders use the same order. */
144149
private static save(map: Record<string, number>): void {
145150
if (BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)) {
151+
document.cookie = `${WORKSPACE_RECENCY_COOKIE}=${serializeWorkspaceRecency(map)}; path=/; max-age=31536000; samesite=lax`
146152
window.dispatchEvent(new Event(WorkspaceRecencyStorage.CHANGE_EVENT))
147153
}
148154
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
parseWorkspaceRecency,
7+
serializeWorkspaceRecency,
8+
sortByRecentIds,
9+
} from '@/lib/workspaces/recency-cookie'
10+
11+
describe('workspace recency cookie', () => {
12+
it('serializes visits most recent first and round-trips through parse', () => {
13+
const value = serializeWorkspaceRecency({ a: 1, b: 3, c: 2 })
14+
expect(value).toBe('b.c.a')
15+
expect(parseWorkspaceRecency(value)).toEqual(['b', 'c', 'a'])
16+
})
17+
18+
it('caps the cookie to the most recent twenty workspaces', () => {
19+
const visits = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`ws-${i}`, i]))
20+
const ids = parseWorkspaceRecency(serializeWorkspaceRecency(visits))
21+
expect(ids).toHaveLength(20)
22+
expect(ids[0]).toBe('ws-29')
23+
})
24+
25+
it('drops malformed ids from a tampered cookie', () => {
26+
expect(parseWorkspaceRecency('ok-1.<script>..ok_2')).toEqual(['ok-1', 'ok_2'])
27+
expect(parseWorkspaceRecency(undefined)).toEqual([])
28+
})
29+
30+
it('orders tracked items first and keeps untracked items in incoming order', () => {
31+
const items = [{ id: 'n1' }, { id: 't2' }, { id: 'n2' }, { id: 't1' }]
32+
expect(sortByRecentIds(items, ['t1', 't2']).map(({ id }) => id)).toEqual([
33+
't1',
34+
't2',
35+
'n1',
36+
'n2',
37+
])
38+
expect(sortByRecentIds(items, [])).toBe(items)
39+
})
40+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Mirrors the viewer's most recent workspace visits into a cookie so the server
3+
* can render the workspace list in the order the browser's visit history will
4+
* produce. Visit history itself stays in localStorage; this is only the head of
5+
* it, capped because the cookie rides along on every request.
6+
*/
7+
export const WORKSPACE_RECENCY_COOKIE = 'workspace_recency'
8+
9+
const MAX_RECENT_WORKSPACES = 20
10+
const SEPARATOR = '.'
11+
const WORKSPACE_ID_PATTERN = /^[\w-]{1,64}$/
12+
13+
/** Most-recent-first workspace ids from a visit-time map, ready for `document.cookie`. */
14+
export function serializeWorkspaceRecency(visits: Record<string, number>): string {
15+
return Object.entries(visits)
16+
.sort((a, b) => b[1] - a[1])
17+
.slice(0, MAX_RECENT_WORKSPACES)
18+
.map(([id]) => id)
19+
.filter((id) => WORKSPACE_ID_PATTERN.test(id))
20+
.join(SEPARATOR)
21+
}
22+
23+
/** Parses the cookie defensively: it is client-written, so malformed ids are dropped. */
24+
export function parseWorkspaceRecency(value: string | undefined): string[] {
25+
if (!value) return []
26+
return value
27+
.split(SEPARATOR)
28+
.filter((id) => WORKSPACE_ID_PATTERN.test(id))
29+
.slice(0, MAX_RECENT_WORKSPACES)
30+
}
31+
32+
/**
33+
* Orders items by their position in `recentIds`, leaving untracked items after
34+
* them in their incoming order — the same result `sortByRecency` gives for
35+
* the ids the cookie holds.
36+
*/
37+
export function sortByRecentIds<T extends { id: string }>(
38+
items: T[],
39+
recentIds: readonly string[]
40+
): T[] {
41+
if (recentIds.length === 0) return items
42+
const rank = new Map(recentIds.map((id, index) => [id, index]))
43+
const untracked = recentIds.length
44+
return [...items].sort((a, b) => (rank.get(a.id) ?? untracked) - (rank.get(b.id) ?? untracked))
45+
}

0 commit comments

Comments
 (0)