Skip to content

Commit 2eaf3aa

Browse files
committed
fix(sidebar): render collapse state from a cookie so SSR matches
The server couldn't read localStorage, so a collapsed user's first paint rendered the *expanded* tree at 51px — prefetched chat/workflow lists, pinned-chat pin icons, and loading skeletons all crammed into the rail and then reflowed once the store hydrated. Mirror the collapse state into a sidebar_collapsed cookie (the shadcn/ui sidebar pattern), read it in the workspace server layout, and seed the sidebar's first render with it: structure is now correct on the server, so the first paint is the real rail with no skeleton/pin/shift. The store remains the post-hydration source of truth; the blocking script honors the cookie for width when localStorage is absent so width and structure agree.
1 parent e2c7f1e commit 2eaf3aa

5 files changed

Lines changed: 52 additions & 9 deletions

File tree

apps/sim/app/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
7979
var defaultSidebarWidth = 248;
8080
try {
8181
var stored = localStorage.getItem('sidebar-state');
82+
// The server renders collapsed/expanded structure from the
83+
// sidebar_collapsed cookie; fall back to it for the width when
84+
// localStorage is absent so width and structure never disagree.
85+
var cookieCollapsed = document.cookie.indexOf('sidebar_collapsed=1') !== -1;
8286
if (stored) {
8387
var parsed = JSON.parse(stored);
8488
var state = parsed && parsed.state;
@@ -96,6 +100,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
96100
: defaultSidebarWidth;
97101
document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
98102
}
103+
} else if (cookieCollapsed) {
104+
document.documentElement.style.setProperty('--sidebar-width', '51px');
105+
document.documentElement.setAttribute('data-sidebar-collapsed', '');
99106
} else {
100107
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
101108
}

apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const SLIDE_TRANSITION =
1515

1616
interface WorkspaceChromeProps {
1717
children: React.ReactNode
18+
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
19+
initialSidebarCollapsed?: boolean
1820
}
1921

2022
function isFullscreenPath(pathname: string | null): boolean {
@@ -41,7 +43,7 @@ function isFullscreenPath(pathname: string | null): boolean {
4143
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
4244
* so no slide plays (CSS transitions don't run on mount).
4345
*/
44-
export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
46+
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
4547
const pathname = usePathname()
4648
const isFullscreen = isFullscreenPath(pathname)
4749

@@ -103,7 +105,7 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
103105
isFullscreen && '-translate-x-full'
104106
)}
105107
>
106-
<Sidebar />
108+
<Sidebar initialCollapsed={initialSidebarCollapsed} />
107109
</div>
108110
</div>
109111
<div

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
2+
import { cookies } from 'next/headers'
23
import { redirect } from 'next/navigation'
34
import { ToastProvider } from '@/components/emcn'
45
import { getSession } from '@/lib/auth'
@@ -27,6 +28,7 @@ export default async function WorkspaceLayout({
2728
}
2829

2930
const { workspaceId } = await params
31+
const initialSidebarCollapsed = (await cookies()).get('sidebar_collapsed')?.value === '1'
3032
const queryClient = getQueryClient()
3133
const sidebarPrefetch = prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id)
3234

@@ -47,7 +49,9 @@ export default async function WorkspaceLayout({
4749
<WorkspacePermissionsProvider>
4850
<WorkspaceScopeSync />
4951
<HydrationBoundary state={dehydrate(queryClient)}>
50-
<WorkspaceChrome>{children}</WorkspaceChrome>
52+
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
53+
{children}
54+
</WorkspaceChrome>
5155
</HydrationBoundary>
5256
</WorkspacePermissionsProvider>
5357
</div>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,18 @@ const HIDDEN_STYLE = { display: 'none' } as const
347347
*
348348
* @returns Sidebar with workflows panel
349349
*/
350-
export const Sidebar = memo(function Sidebar() {
350+
interface SidebarProps {
351+
/**
352+
* Collapse state read from the `sidebar_collapsed` cookie in the server
353+
* layout. Seeds the first (pre-hydration) render so the server emits the
354+
* correct collapsed/expanded structure — without it the server can't read
355+
* `localStorage` and always renders the expanded tree, which then paints
356+
* skeletons and pinned-chat icons inside the 51px rail until React flips it.
357+
*/
358+
initialCollapsed?: boolean
359+
}
360+
361+
export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: SidebarProps) {
351362
const params = useParams()
352363
const workspaceId = params.workspaceId as string
353364
const workflowId = params.workflowId as string | undefined
@@ -378,15 +389,21 @@ export const Sidebar = memo(function Sidebar() {
378389
}, [initializeSearchData, filterBlocks, providerModelSignature])
379390

380391
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
381-
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
392+
const storeIsCollapsed = useSidebarStore((state) => state.isCollapsed)
393+
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
382394
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
383395
const isOnWorkflowPage = !!workflowId
384396

397+
// Until the persisted store hydrates, fall back to the cookie-seeded value so
398+
// the server and the first client render agree (no hydration mismatch) and the
399+
// correct structure paints immediately. After hydration the store — the source
400+
// of truth, including cross-tab updates — takes over.
401+
const isCollapsed = hasHydrated ? storeIsCollapsed : initialCollapsed
402+
385403
// Hydrate the persisted sidebar state before the browser paints. The store
386-
// sets `skipHydration` so its default (`isCollapsed: false`) matches the SSR
387-
// HTML on first render; flushing rehydration here re-renders the correct
388-
// collapsed/expanded structure synchronously in the same pre-paint commit,
389-
// preventing the expanded tree from flashing inside the collapsed rail.
404+
// sets `skipHydration` so its default matches the cookie-seeded first render;
405+
// flushing rehydration here reconciles any drift synchronously in the same
406+
// pre-paint commit instead of reflowing after paint.
390407
useLayoutEffect(() => {
391408
void useSidebarStore.persist.rehydrate()
392409
}, [])

apps/sim/stores/sidebar/store.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ function applySidebarWidth(width: number) {
2424
document.documentElement.style.setProperty('--sidebar-width', `${value}px`)
2525
}
2626

27+
/**
28+
* Mirrors the collapse state into the `sidebar_collapsed` cookie so the server
29+
* layout can render the correct structure on the first paint (the store itself
30+
* lives in `localStorage`, which the server can't read). Written on every toggle
31+
* and once on rehydration to backfill the cookie for already-persisted users.
32+
*/
33+
function applyCollapsedCookie(collapsed: boolean) {
34+
if (typeof document === 'undefined') return
35+
document.cookie = `sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
36+
}
37+
2738
export const useSidebarStore = create<SidebarState>()(
2839
persist(
2940
(set, get) => ({
@@ -42,6 +53,7 @@ export const useSidebarStore = create<SidebarState>()(
4253
const { isCollapsed, sidebarWidth } = get()
4354
const nextCollapsed = !isCollapsed
4455
set({ isCollapsed: nextCollapsed })
56+
applyCollapsedCookie(nextCollapsed)
4557
applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth))
4658
},
4759
syncWidth: () => {
@@ -71,6 +83,7 @@ export const useSidebarStore = create<SidebarState>()(
7183
onRehydrateStorage: () => (state) => {
7284
if (state) {
7385
state.setHasHydrated(true)
86+
applyCollapsedCookie(state.isCollapsed)
7487
const width = state.isCollapsed
7588
? SIDEBAR_WIDTH.COLLAPSED
7689
: clampSidebarWidth(state.sidebarWidth)

0 commit comments

Comments
 (0)