Fix sidebar hydration flash

This commit is contained in:
2026-03-12 16:29:28 -04:00
parent b39bc9eccd
commit b9a1d8ba40
4 changed files with 74 additions and 12 deletions

View File

@@ -1,6 +1,12 @@
import './globals.css'; import './globals.css';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import { SidebarPreferenceProvider } from '@/components/providers/sidebar-preference-provider';
import { QueryProvider } from '@/components/providers/query-provider'; import { QueryProvider } from '@/components/providers/query-provider';
import {
SIDEBAR_PREFERENCE_KEY,
parseSidebarPreference
} from '@/lib/sidebar-preference';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Fiscal Clone', title: 'Fiscal Clone',
@@ -14,11 +20,18 @@ export const viewport: Viewport = {
themeColor: '#121417' themeColor: '#121417'
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const initialSidebarCollapsed = parseSidebarPreference(
cookieStore.get(SIDEBAR_PREFERENCE_KEY)?.value
);
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<SidebarPreferenceProvider initialSidebarCollapsed={initialSidebarCollapsed}>
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
</SidebarPreferenceProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,32 @@
'use client';
import { createContext, useContext } from 'react';
type SidebarPreferenceContextValue = {
initialSidebarCollapsed: boolean;
};
const SidebarPreferenceContext =
createContext<SidebarPreferenceContextValue>({
initialSidebarCollapsed: false
});
type SidebarPreferenceProviderProps = {
children: React.ReactNode;
initialSidebarCollapsed: boolean;
};
export function SidebarPreferenceProvider({
children,
initialSidebarCollapsed
}: SidebarPreferenceProviderProps) {
return (
<SidebarPreferenceContext.Provider value={{ initialSidebarCollapsed }}>
{children}
</SidebarPreferenceContext.Provider>
);
}
export function useSidebarPreference() {
return useContext(SidebarPreferenceContext);
}

View File

@@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from "react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { TaskDetailModal } from "@/components/notifications/task-detail-modal"; import { TaskDetailModal } from "@/components/notifications/task-detail-modal";
import { TaskNotificationsTrigger } from "@/components/notifications/task-notifications-trigger"; import { TaskNotificationsTrigger } from "@/components/notifications/task-notifications-trigger";
import { useSidebarPreference } from "@/components/providers/sidebar-preference-provider";
import { import {
companyAnalysisQueryOptions, companyAnalysisQueryOptions,
companyFinancialStatementsQueryOptions, companyFinancialStatementsQueryOptions,
@@ -37,6 +38,7 @@ import { buildGraphingHref } from "@/lib/graphing/catalog";
import type { ActiveContext, NavGroup, NavItem } from "@/lib/types"; import type { ActiveContext, NavGroup, NavItem } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTaskNotificationsCenter } from "@/hooks/use-task-notifications-center"; import { useTaskNotificationsCenter } from "@/hooks/use-task-notifications-center";
import { SIDEBAR_PREFERENCE_KEY } from "@/lib/sidebar-preference";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type AppShellProps = { type AppShellProps = {
@@ -148,8 +150,6 @@ const GROUP_LABELS: Record<NavGroup, string> = {
portfolio: "Portfolio", portfolio: "Portfolio",
}; };
const SIDEBAR_PREFERENCE_KEY = "fiscal-shell-sidebar-collapsed";
function normalizeTicker(value: string | null | undefined) { function normalizeTicker(value: string | null | undefined) {
const normalized = value?.trim().toUpperCase() ?? ""; const normalized = value?.trim().toUpperCase() ?? "";
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
@@ -260,12 +260,16 @@ export function AppShell({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { initialSidebarCollapsed } = useSidebarPreference();
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const [isMoreOpen, setIsMoreOpen] = useState(false); const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
const [hasLoadedSidebarPreference, setHasLoadedSidebarPreference] = initialSidebarCollapsed,
);
const [hasResolvedSidebarPreference, setHasResolvedSidebarPreference] =
useState(false); useState(false);
const [hasMounted, setHasMounted] = useState(false);
const notifications = useTaskNotificationsCenter(); const notifications = useTaskNotificationsCenter();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const sessionUser = (session?.user ?? null) as { const sessionUser = (session?.user ?? null) as {
@@ -433,14 +437,14 @@ export function AppShell({
const storedPreference = window.localStorage.getItem( const storedPreference = window.localStorage.getItem(
SIDEBAR_PREFERENCE_KEY, SIDEBAR_PREFERENCE_KEY,
); );
if (storedPreference === "true") { if (storedPreference === "true" || storedPreference === "false") {
setIsSidebarCollapsed(true); setIsSidebarCollapsed(storedPreference === "true");
} }
setHasLoadedSidebarPreference(true); setHasResolvedSidebarPreference(true);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!hasLoadedSidebarPreference) { if (!hasResolvedSidebarPreference) {
return; return;
} }
@@ -448,7 +452,12 @@ export function AppShell({
SIDEBAR_PREFERENCE_KEY, SIDEBAR_PREFERENCE_KEY,
String(isSidebarCollapsed), String(isSidebarCollapsed),
); );
}, [hasLoadedSidebarPreference, isSidebarCollapsed]); document.cookie = `${SIDEBAR_PREFERENCE_KEY}=${String(isSidebarCollapsed)}; path=/; max-age=31536000; samesite=lax`;
}, [hasResolvedSidebarPreference, isSidebarCollapsed]);
useEffect(() => {
setHasMounted(true);
}, []);
useEffect(() => { useEffect(() => {
const browserWindow = window as Window & { const browserWindow = window as Window & {
@@ -520,7 +529,8 @@ export function AppShell({
> >
<aside <aside
className={cn( className={cn(
"hidden shrink-0 flex-col gap-4 border-r border-[color:var(--line-weak)] transition-[width,padding] duration-200 lg:flex", "hidden shrink-0 flex-col gap-4 border-r border-[color:var(--line-weak)] lg:flex",
hasMounted ? "transition-[width,padding] duration-200" : "",
isSidebarCollapsed ? "w-16 pr-1" : "w-72 pr-4", isSidebarCollapsed ? "w-16 pr-1" : "w-72 pr-4",
)} )}
> >

View File

@@ -0,0 +1,7 @@
export const SIDEBAR_PREFERENCE_KEY = "fiscal-shell-sidebar-collapsed";
export function parseSidebarPreference(
value: string | null | undefined,
): boolean {
return value === "true";
}