Skip to content

Commit 8842ad7

Browse files
committed
fix(sidebar): drive collapsed width from server-rendered attribute
A collapsed rail painted at the expanded width then animated to 51px on refresh: structure came from the cookie (server) while width came from independent cookie reads (blocking script + store), so any disagreement left the collapsed structure at the persisted expanded width until the store corrected it. Unify collapse into one derivation in WorkspaceChrome and drive the collapsed width from a server-rendered data-collapsed attribute via CSS (.sidebar-shell-outer[data-collapsed]) — the same cookie source as the structure, so width can never diverge from it. This is shadcn's documented pattern (data-attribute selectors over JS ternaries for collapsed dimensions). Also removes the redundant migratedCollapsed reconciliation (the store already seeds from the migrated cookie and hasHydrated flips in the same pre-paint effect) and the now-unused per-Sidebar derivation; Sidebar takes isCollapsed as a prop.
1 parent f3035ab commit 8842ad7

3 files changed

Lines changed: 40 additions & 39 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@
5555
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
5656
}
5757

58+
/**
59+
* Collapsed width is driven by the server-rendered `data-collapsed` attribute —
60+
* the same cookie source as the collapsed structure — so the rail can never paint
61+
* at the expanded width and then snap narrow. Overrides `--sidebar-width` for the
62+
* shell subtree (outer, inner, and the aside cascade from it). Must equal
63+
* SIDEBAR_WIDTH.COLLAPSED in stores/constants.ts.
64+
*/
65+
.sidebar-shell-outer[data-collapsed] {
66+
--sidebar-width: 51px;
67+
}
68+
5869
.sidebar-container span,
5970
.sidebar-container .text-small {
6071
transition: opacity 120ms ease;

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect } from 'react'
3+
import { useEffect, useLayoutEffect } from 'react'
44
import { usePathname } from 'next/navigation'
55
import { cn } from '@/lib/core/utils/cn'
66
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -43,15 +43,34 @@ function isFullscreenPath(pathname: string | null): boolean {
4343
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
4444
* so no slide plays (CSS transitions don't run on mount).
4545
*/
46-
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
46+
export function WorkspaceChrome({
47+
children,
48+
initialSidebarCollapsed = false,
49+
}: WorkspaceChromeProps) {
4750
const pathname = usePathname()
4851
const isFullscreen = isFullscreenPath(pathname)
4952

5053
const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
5154

55+
const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed)
5256
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
5357
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
5458

59+
/**
60+
* Single source of collapse for the whole chrome, driving the rail's structure,
61+
* labels, and width. The server renders from the `sidebar_collapsed` cookie
62+
* (`initialSidebarCollapsed`) and the store seeds from the same cookie — after
63+
* the pre-paint script migrates any legacy `localStorage` flag — so prop and
64+
* store agree. The prop is used until the store hydrates (keeping the first
65+
* client render identical to the server), then the store takes over.
66+
*/
67+
const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed
68+
69+
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
70+
useLayoutEffect(() => {
71+
void useSidebarStore.persist.rehydrate()
72+
}, [])
73+
5574
// Remember the last non-fullscreen page so a fullscreen route's Back control
5675
// can return there, deterministically and for any trigger.
5776
useEffect(() => {
@@ -95,6 +114,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
95114
SLIDE_TRANSITION,
96115
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
97116
)}
117+
data-collapsed={isCollapsed || undefined}
98118
aria-hidden={isFullscreen || undefined}
99119
suppressHydrationWarning
100120
>
@@ -105,7 +125,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
105125
isFullscreen && '-translate-x-full'
106126
)}
107127
>
108-
<Sidebar initialCollapsed={initialSidebarCollapsed} />
128+
<Sidebar isCollapsed={isCollapsed} />
109129
</div>
110130
</div>
111131
<div

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

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
109109
import { useFolderStore } from '@/stores/folders/store'
110110
import { useSearchModalStore } from '@/stores/modals/search/store'
111111
import { useProvidersStore } from '@/stores/providers'
112-
import { readCollapsedCookie, useSidebarStore } from '@/stores/sidebar/store'
112+
import { useSidebarStore } from '@/stores/sidebar/store'
113113

114114
const logger = createLogger('Sidebar')
115115

@@ -349,16 +349,14 @@ const HIDDEN_STYLE = { display: 'none' } as const
349349
*/
350350
interface SidebarProps {
351351
/**
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.
352+
* Authoritative collapse state, derived once in {@link WorkspaceChrome} from the
353+
* `sidebar_collapsed` cookie (server prop → store after hydration) and passed in
354+
* so the rail's structure, labels, and width all read a single source.
357355
*/
358-
initialCollapsed?: boolean
356+
isCollapsed: boolean
359357
}
360358

361-
export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: SidebarProps) {
359+
export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
362360
const params = useParams()
363361
const workspaceId = params.workspaceId as string
364362
const workflowId = params.workflowId as string | undefined
@@ -389,37 +387,9 @@ export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: Sideb
389387
}, [initializeSearchData, filterBlocks, providerModelSignature])
390388

391389
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
392-
const storeIsCollapsed = useSidebarStore((state) => state.isCollapsed)
393-
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
394390
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
395391
const isOnWorkflowPage = !!workflowId
396392

397-
/**
398-
* The server renders from the `sidebar_collapsed` cookie (via `initialCollapsed`)
399-
* and the client store seeds from the same cookie, so both agree on the first
400-
* paint. The prop is read until the store reports hydration, after which the
401-
* store takes over.
402-
*
403-
* A legacy user whose collapse lived only in `localStorage` has no cookie at SSR
404-
* (so `initialCollapsed` is false), but the pre-paint script migrates them to a
405-
* cookie. Reconcile to that cookie synchronously before paint — the first render
406-
* still matches the server, so there's no hydration mismatch and no narrow-rail flash.
407-
*/
408-
const [migratedCollapsed, setMigratedCollapsed] = useState<boolean | null>(null)
409-
useLayoutEffect(() => {
410-
const cookieCollapsed = readCollapsedCookie()
411-
if (cookieCollapsed !== initialCollapsed) setMigratedCollapsed(cookieCollapsed)
412-
}, [initialCollapsed])
413-
const isCollapsed = hasHydrated ? storeIsCollapsed : (migratedCollapsed ?? initialCollapsed)
414-
415-
/**
416-
* Hydrates the persisted width before paint (collapse already came from the
417-
* cookie) so any width-dependent layout settles in the same commit.
418-
*/
419-
useLayoutEffect(() => {
420-
void useSidebarStore.persist.rehydrate()
421-
}, [])
422-
423393
const isCollapsedRef = useRef(isCollapsed)
424394
useLayoutEffect(() => {
425395
isCollapsedRef.current = isCollapsed

0 commit comments

Comments
 (0)