Skip to content

Commit f8fa284

Browse files
committed
refactor(sidebar): make the cookie the single source of truth for collapse
Consolidates the collapse machinery onto one source of truth instead of layering the cookie on top of the legacy localStorage + CSS-mask system: - Collapse persists only in the sidebar_collapsed cookie; the store seeds isCollapsed from it and drops it from localStorage (partialize + merge), removing the dual-write and the cross-tab desync it caused. - Retire the redundant html[data-sidebar-collapsed] attribute + CSS mask now that the server emits the correct data-collapsed structure; also delete the dead sidebar-collapse-show/-remove/-btn rules. - Blocking script reads the cookie for collapse (width stays in localStorage) and seeds the cookie once from the legacy flag so existing collapsed users keep their preference. - Keep skipHydration + a pre-paint rehydrate for width only — the documented zustand SSR pattern, so _hasHydrated is deterministically false during SSR. Width stays in localStorage; each field now has exactly one home.
1 parent 2eaf3aa commit f8fa284

4 files changed

Lines changed: 51 additions & 78 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,38 +66,11 @@
6666
opacity: 0;
6767
}
6868

69-
html[data-sidebar-collapsed] .sidebar-container span,
70-
html[data-sidebar-collapsed] .sidebar-container .text-small {
71-
opacity: 0;
72-
}
73-
7469
.sidebar-container .sidebar-collapse-hide {
7570
transition: opacity 60ms ease;
7671
}
7772

78-
.sidebar-container .sidebar-collapse-show {
79-
opacity: 0;
80-
pointer-events: none;
81-
transition: opacity 120ms ease-out;
82-
}
83-
84-
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
85-
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
86-
opacity: 0;
87-
}
88-
89-
.sidebar-container[data-collapsed] .sidebar-collapse-show,
90-
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
91-
opacity: 1;
92-
pointer-events: auto;
93-
}
94-
95-
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
96-
display: none;
97-
}
98-
99-
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
100-
width: 0;
73+
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
10174
opacity: 0;
10275
}
10376

apps/sim/app/layout.tsx

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,33 +78,30 @@ export default function RootLayout({ children }: { children: React.ReactNode })
7878
// window yields a width >= MIN instead of a sub-minimum sliver.
7979
var defaultSidebarWidth = 248;
8080
try {
81+
// Collapse is owned by the sidebar_collapsed cookie (the same
82+
// source the server renders structure from); width is read from
83+
// localStorage so the two never disagree on the first paint.
8184
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;
86-
if (stored) {
87-
var parsed = JSON.parse(stored);
88-
var state = parsed && parsed.state;
89-
var isCollapsed = state && state.isCollapsed;
85+
var state = stored ? JSON.parse(stored).state : null;
86+
var collapsed = document.cookie.indexOf('sidebar_collapsed=1') !== -1;
87+
88+
// One-time migration: seed the cookie from the legacy localStorage
89+
// flag for users who collapsed before the cookie existed.
90+
if (document.cookie.indexOf('sidebar_collapsed=') === -1 && state && typeof state.isCollapsed === 'boolean') {
91+
collapsed = state.isCollapsed;
92+
document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax';
93+
}
9094
91-
if (isCollapsed) {
92-
document.documentElement.style.setProperty('--sidebar-width', '51px');
93-
document.documentElement.setAttribute('data-sidebar-collapsed', '');
94-
} else {
95-
var width = state && state.sidebarWidth;
96-
var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
97-
var finalWidth =
98-
typeof width === 'number' && isFinite(width)
99-
? Math.min(Math.max(width, 248), maxSidebarWidth)
100-
: defaultSidebarWidth;
101-
document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
102-
}
103-
} else if (cookieCollapsed) {
95+
if (collapsed) {
10496
document.documentElement.style.setProperty('--sidebar-width', '51px');
105-
document.documentElement.setAttribute('data-sidebar-collapsed', '');
10697
} else {
107-
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
98+
var width = state && state.sidebarWidth;
99+
var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
100+
var finalWidth =
101+
typeof width === 'number' && isFinite(width)
102+
? Math.min(Math.max(width, 248), maxSidebarWidth)
103+
: defaultSidebarWidth;
104+
document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
108105
}
109106
} catch (e) {
110107
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,16 +394,14 @@ export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: Sideb
394394
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
395395
const isOnWorkflowPage = !!workflowId
396396

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.
397+
// The server renders from the `sidebar_collapsed` cookie (via `initialCollapsed`);
398+
// the client store seeds from the same cookie, so both agree on the first paint.
399+
// Until the store reports hydration we read the prop to guarantee that match,
400+
// then the store takes over.
401401
const isCollapsed = hasHydrated ? storeIsCollapsed : initialCollapsed
402402

403-
// Hydrate the persisted sidebar state before the browser paints. The store
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.
403+
// Hydrate the persisted width before paint (collapse already came from the
404+
// cookie). Pre-paint so any width-dependent layout settles in the same commit.
407405
useLayoutEffect(() => {
408406
void useSidebarStore.persist.rehydrate()
409407
}, [])

apps/sim/stores/sidebar/store.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,28 @@ function applySidebarWidth(width: number) {
2525
}
2626

2727
/**
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.
28+
* The `sidebar_collapsed` cookie is the single source of truth for collapse: the
29+
* server layout reads it to render the correct structure on the first paint
30+
* (it can't read `localStorage`), and the client seeds its initial state from it
31+
* below. Width is the only field persisted to `localStorage`.
3232
*/
3333
function applyCollapsedCookie(collapsed: boolean) {
3434
if (typeof document === 'undefined') return
3535
document.cookie = `sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
3636
}
3737

38+
/** Reads the collapse state the server saw, so the client store seeds identically. */
39+
function readCollapsedCookie(): boolean {
40+
if (typeof document === 'undefined') return false
41+
return document.cookie.includes('sidebar_collapsed=1')
42+
}
43+
3844
export const useSidebarStore = create<SidebarState>()(
3945
persist(
4046
(set, get) => ({
4147
workspaceDropdownOpen: false,
4248
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
43-
isCollapsed: false,
49+
isCollapsed: readCollapsedCookie(),
4450
_hasHydrated: false,
4551
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
4652
setSidebarWidth: (width) => {
@@ -71,31 +77,30 @@ export const useSidebarStore = create<SidebarState>()(
7177
{
7278
name: 'sidebar-state',
7379
/**
74-
* Hydration is driven manually from a `useLayoutEffect` (see Sidebar) so it
75-
* runs synchronously before the first paint. Auto-hydration would either
76-
* (a) run at module load and make the client's first render disagree with
77-
* the server's `isCollapsed: false` HTML — a mismatch React recovers from by
78-
* flashing the server tree, or (b) run after paint, reflowing the expanded
79-
* tree into the collapsed rail. Skipping it lets the layout effect flip the
80-
* structure in the same pre-paint commit, so neither flash is visible.
80+
* Width is hydrated manually from a client-only effect (see Sidebar) so
81+
* `_hasHydrated` is deterministically `false` during SSR and the first
82+
* client render — both of which read collapse from the cookie-seeded prop.
83+
* This is zustand's documented SSR pattern; it avoids relying on auto
84+
* hydration's behavior when `localStorage` is absent on the server.
8185
*/
8286
skipHydration: true,
8387
onRehydrateStorage: () => (state) => {
8488
if (state) {
8589
state.setHasHydrated(true)
86-
applyCollapsedCookie(state.isCollapsed)
8790
const width = state.isCollapsed
8891
? SIDEBAR_WIDTH.COLLAPSED
8992
: clampSidebarWidth(state.sidebarWidth)
9093
applySidebarWidth(width)
91-
if (typeof document !== 'undefined') {
92-
document.documentElement.removeAttribute('data-sidebar-collapsed')
93-
}
9494
}
9595
},
96-
partialize: (state) => ({
97-
sidebarWidth: state.sidebarWidth,
98-
isCollapsed: state.isCollapsed,
96+
// Only width is persisted; collapse lives in the cookie.
97+
partialize: (state) => ({ sidebarWidth: state.sidebarWidth }),
98+
// Never let a legacy persisted `isCollapsed` override the cookie-seeded
99+
// value — the cookie is the source of truth (handles migration cleanly).
100+
merge: (persisted, current) => ({
101+
...current,
102+
...(persisted as Partial<SidebarState>),
103+
isCollapsed: current.isCollapsed,
99104
}),
100105
}
101106
)

0 commit comments

Comments
 (0)