From eb8fec73a49c5a20eaeb835b37e79ee59c99f575 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 17:26:14 -0700 Subject: [PATCH 1/2] fix(sidebar): render workspace recency order on the server to stop refresh reshuffle --- .../use-organization-workspaces.test.tsx | 30 +++++++++++-- apps/sim/app/o/[organizationId]/layout.tsx | 17 ++++--- .../app/workspace/[workspaceId]/layout.tsx | 17 ++++--- .../workspaces/workspace-recency-provider.tsx | 32 +++++++++++++ apps/sim/hooks/use-workspace-order.ts | 14 ++++-- apps/sim/lib/core/utils/browser-storage.ts | 6 +++ .../sim/lib/workspaces/recency-cookie.test.ts | 40 +++++++++++++++++ apps/sim/lib/workspaces/recency-cookie.ts | 45 +++++++++++++++++++ 8 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 apps/sim/components/workspaces/workspace-recency-provider.tsx create mode 100644 apps/sim/lib/workspaces/recency-cookie.test.ts create mode 100644 apps/sim/lib/workspaces/recency-cookie.ts diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx index 6e639163a73..ef81d8f5d48 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx @@ -5,7 +5,9 @@ import { act } from 'react' import { createRoot, hydrateRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider' import { STORAGE_KEYS, WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' +import { WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie' const { mockUseWorkspacesQuery, pins } = vi.hoisted(() => ({ pins: { current: new Set() }, @@ -64,20 +66,31 @@ function workspaceIds() { } describe('useOrganizationWorkspaces', () => { - it('hydrates the prefetched order before applying visit history without changing the query cache', async () => { + it('renders the visit order on the server so hydration does not reshuffle rows', async () => { localStorage.setItem( STORAGE_KEYS.WORKSPACE_RECENCY, JSON.stringify({ oldest: 100, older: 200, 'other-org': 300 }) ) - container.innerHTML = renderToString() - expect(workspaceIds()).toEqual(['newest', 'older', 'oldest']) + const recentIds = ['other-org', 'older', 'oldest'] + const tree = ( + + + + ) + container.innerHTML = renderToString(tree) + expect(workspaceIds()).toEqual(['older', 'oldest', 'newest']) const onRecoverableError = vi.fn() + const observed: string[][] = [] + const observer = new MutationObserver(() => observed.push(workspaceIds())) + observer.observe(container, { childList: true, subtree: true, characterData: true }) await act(async () => { - root = hydrateRoot(container, , { onRecoverableError }) + root = hydrateRoot(container, tree, { onRecoverableError }) }) + observer.disconnect() expect(onRecoverableError).not.toHaveBeenCalled() + expect(observed).toEqual([]) expect(workspaceIds()).toEqual(['older', 'oldest', 'newest']) expect(mockUseWorkspacesQuery().data.map(({ id }: { id: string }) => id)).toEqual([ 'newest', @@ -87,6 +100,15 @@ describe('useOrganizationWorkspaces', () => { ]) }) + it('mirrors visits into the recency cookie', async () => { + await act(async () => { + root = createRoot(container) + root.render() + }) + await act(async () => WorkspaceRecencyStorage.touch('older')) + expect(document.cookie).toContain(`${WORKSPACE_RECENCY_COOKIE}=older`) + }) + it('preserves creation-date order when the browser has no visit history', async () => { await act(async () => { root = createRoot(container) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 82e18bbdacb..ea48d4f285f 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -1,12 +1,14 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' +import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' +import { parseWorkspaceRecency, WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied' @@ -66,6 +68,7 @@ export default async function OrganizationLayout({ isPlanModeEnabled(), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' + const recentWorkspaceIds = parseWorkspaceRecency(cookieStore.get(WORKSPACE_RECENCY_COOKIE)?.value) return ( @@ -81,12 +84,14 @@ export default async function OrganizationLayout({
- } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + +
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index d875a37ba6a..3c5670139b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,11 +1,13 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' +import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' +import { parseWorkspaceRecency, WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -81,6 +83,7 @@ export default async function WorkspaceLayout({ }), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' + const recentWorkspaceIds = parseWorkspaceRecency(cookieStore.get(WORKSPACE_RECENCY_COOKIE)?.value) return ( @@ -108,12 +111,14 @@ export default async function WorkspaceLayout({ - } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + + diff --git a/apps/sim/components/workspaces/workspace-recency-provider.tsx b/apps/sim/components/workspaces/workspace-recency-provider.tsx new file mode 100644 index 00000000000..2ddb8bddf24 --- /dev/null +++ b/apps/sim/components/workspaces/workspace-recency-provider.tsx @@ -0,0 +1,32 @@ +'use client' + +import { createContext, useContext } from 'react' + +const EMPTY_RECENT_WORKSPACE_IDS: readonly string[] = [] + +const WorkspaceRecencyContext = createContext(EMPTY_RECENT_WORKSPACE_IDS) + +interface WorkspaceRecencyProviderProps { + /** Most-recent-first workspace ids read from the recency cookie by the layout. */ + recentWorkspaceIds: readonly string[] + children: React.ReactNode +} + +/** + * Hands the server-read visit order to the workspace lists so server render and + * hydration agree with the order the browser's visit history applies after it. + */ +export function WorkspaceRecencyProvider({ + recentWorkspaceIds, + children, +}: WorkspaceRecencyProviderProps) { + return ( + + {children} + + ) +} + +export function useInitialRecentWorkspaceIds(): readonly string[] { + return useContext(WorkspaceRecencyContext) +} diff --git a/apps/sim/hooks/use-workspace-order.ts b/apps/sim/hooks/use-workspace-order.ts index 6d3438607ec..48dec9c36cd 100644 --- a/apps/sim/hooks/use-workspace-order.ts +++ b/apps/sim/hooks/use-workspace-order.ts @@ -1,13 +1,21 @@ 'use client' import { useMemo, useSyncExternalStore } from 'react' +import { useInitialRecentWorkspaceIds } from '@/components/workspaces/workspace-recency-provider' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' +import { sortByRecentIds } from '@/lib/workspaces/recency-cookie' import type { Workspace } from '@/hooks/queries/workspace' const serverSnapshot = () => null -/** Layers the viewer's pins and visit history over the server's newest-first list. */ +/** + * Layers the viewer's pins and visit history over the server's newest-first list. + * Server render and hydration order by the recency cookie, which mirrors the head + * of the localStorage history, so switching to that history after hydration does + * not reshuffle the rows. + */ export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySet) { + const initialRecentIds = useInitialRecentWorkspaceIds() const recencySnapshot = useSyncExternalStore( WorkspaceRecencyStorage.subscribe, WorkspaceRecencyStorage.getSnapshot, @@ -16,7 +24,7 @@ export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySe return useMemo(() => { const byRecency = recencySnapshot ? WorkspaceRecencyStorage.sortByRecency(workspaces) - : workspaces + : sortByRecentIds(workspaces, initialRecentIds) return [...byRecency].sort((a, b) => Number(pinnedIds.has(b.id)) - Number(pinnedIds.has(a.id))) - }, [workspaces, pinnedIds, recencySnapshot]) + }, [workspaces, pinnedIds, recencySnapshot, initialRecentIds]) } diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index e799b957cb8..b412b29143d 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -3,6 +3,10 @@ import { workspaceSearchFiltersSchema, } from '@/lib/api/contracts/knowledge/search' import { AssistantSearchLevel } from '@/lib/mothership/generated/assistant' +import { + serializeWorkspaceRecency, + WORKSPACE_RECENCY_COOKIE, +} from '@/lib/workspaces/recency-cookie' /** * Safe localStorage utilities with SSR support * Provides clean error handling and type safety for browser storage operations @@ -141,8 +145,10 @@ export class WorkspaceRecencyStorage { } } + /** Also mirrors the head of the history into a cookie so server renders use the same order. */ private static save(map: Record): void { if (BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)) { + document.cookie = `${WORKSPACE_RECENCY_COOKIE}=${serializeWorkspaceRecency(map)}; path=/; max-age=31536000; samesite=lax` window.dispatchEvent(new Event(WorkspaceRecencyStorage.CHANGE_EVENT)) } } diff --git a/apps/sim/lib/workspaces/recency-cookie.test.ts b/apps/sim/lib/workspaces/recency-cookie.test.ts new file mode 100644 index 00000000000..60fed70d9e8 --- /dev/null +++ b/apps/sim/lib/workspaces/recency-cookie.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + parseWorkspaceRecency, + serializeWorkspaceRecency, + sortByRecentIds, +} from '@/lib/workspaces/recency-cookie' + +describe('workspace recency cookie', () => { + it('serializes visits most recent first and round-trips through parse', () => { + const value = serializeWorkspaceRecency({ a: 1, b: 3, c: 2 }) + expect(value).toBe('b.c.a') + expect(parseWorkspaceRecency(value)).toEqual(['b', 'c', 'a']) + }) + + it('caps the cookie to the most recent twenty workspaces', () => { + const visits = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`ws-${i}`, i])) + const ids = parseWorkspaceRecency(serializeWorkspaceRecency(visits)) + expect(ids).toHaveLength(20) + expect(ids[0]).toBe('ws-29') + }) + + it('drops malformed ids from a tampered cookie', () => { + expect(parseWorkspaceRecency('ok-1.