Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SidebarProvider
className="h-dvh! min-h-0!"
data-panel-animations={panelAnimationsActive ? "true" : "false"}
defaultOpen
style={sidebarProviderStyle}
>
<ProjectProjectionRetention />
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
import { afterEach, describe, expect, it } from "vite-plus/test";

import { removeLocalStorageItem } from "~/hooks/useLocalStorage";

import {
SidebarMenuAction,
Expand All @@ -8,7 +10,12 @@ import {
SidebarProvider,
SidebarTrigger,
} from "./sidebar";
import { resolveSidebarState } from "./sidebarState";
import {
readPersistedSidebarOpen,
resolveSidebarState,
SIDEBAR_OPEN_STORAGE_KEY,
writePersistedSidebarOpen,
} from "./sidebarState";

function renderSidebarButton(className?: string) {
return renderToStaticMarkup(
Expand All @@ -19,6 +26,10 @@ function renderSidebarButton(className?: string) {
}

describe("sidebar interactive cursors", () => {
afterEach(() => {
removeLocalStorageItem(SIDEBAR_OPEN_STORAGE_KEY);
});

it("uses mobile sheet visibility for the shared responsive state", () => {
expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
"collapsed",
Expand All @@ -29,6 +40,18 @@ describe("sidebar interactive cursors", () => {
);
});

it("persists and restores desktop open state", () => {
writePersistedSidebarOpen(false);
expect(readPersistedSidebarOpen()).toBe(false);

const html = renderToStaticMarkup(
<SidebarProvider>
<div />
</SidebarProvider>,
);
expect(html).toContain('data-sidebar-state="collapsed"');
});

it("exposes collapsed state for shared titlebar inset styling", () => {
const html = renderToStaticMarkup(
<SidebarProvider defaultOpen={false}>
Expand Down
21 changes: 9 additions & 12 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ import { Skeleton } from "~/components/ui/skeleton";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
import {
readPersistedSidebarOpen,
resolveSidebarState,
writePersistedSidebarOpen,
type ResponsiveSidebarState,
} from "./sidebarState";
import * as Schema from "effect/Schema";

const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
const SIDEBAR_WIDTH_ICON = "3rem";
Expand Down Expand Up @@ -109,24 +112,18 @@ function SidebarProvider({

// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const [_open, _setOpen] = React.useState(() => readPersistedSidebarOpen(defaultOpen));
const open = openProp ?? _open;
const setOpen = React.useCallback(
async (value: boolean | ((value: boolean) => boolean)) => {
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}

// This sets the cookie to keep the sidebar state.
await cookieStore.set({
expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
name: SIDEBAR_COOKIE_NAME,
path: "/",
value: String(openState),
});
writePersistedSidebarOpen(openState);
},
[setOpenProp, open],
);
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/ui/sidebarState.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import * as Schema from "effect/Schema";

import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";

export type ResponsiveSidebarState = "expanded" | "collapsed";

export const SIDEBAR_OPEN_STORAGE_KEY = "t3code:sidebar-open";

export function resolveSidebarState(input: {
isMobile: boolean;
open: boolean;
openMobile: boolean;
}): ResponsiveSidebarState {
return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed";
}

export function readPersistedSidebarOpen(fallback = true): boolean {
try {
return getLocalStorageItem(SIDEBAR_OPEN_STORAGE_KEY, Schema.Boolean) ?? fallback;
} catch {
return fallback;
}
}

export function writePersistedSidebarOpen(open: boolean): void {
try {
setLocalStorageItem(SIDEBAR_OPEN_STORAGE_KEY, open, Schema.Boolean);
} catch {
// Preference persistence is best-effort.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hand-rolled sidebar persistence helpers

Medium Severity

This adds readPersistedSidebarOpen / writePersistedSidebarOpen and seeds useState from them for a simple boolean UI preference. That violates the project convention to prefer the existing useLocalStorage hook for this kind of persistence — the same pattern already used for sidebar shelf expand state — instead of hand-rolled read/write helpers plus a manual state initializer.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Prefer useLocalStorage hook over hand-rolled localStorage persistence

Reviewed by Cursor Bugbot for commit be99c0066493bd56a9b0b94cb202129b16e3bb5a. Configure here.

Loading