diff --git a/console-ui/__tests__/store-hydration.test.ts b/console-ui/__tests__/store-hydration.test.ts new file mode 100644 index 00000000..a88f33a1 --- /dev/null +++ b/console-ui/__tests__/store-hydration.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Regression coverage for the "nav buttons unclickable after login" bug. +// +// Root cause: the store computed its initial `sidebarOpen` from +// `window.innerWidth` and let zustand's `persist` middleware rehydrate +// synchronously on load. Both make the FIRST client render diverge from the +// server HTML, which triggers a React hydration mismatch (#418). React then +// regenerates the whole tree, re-mounting the freshly-SSR'd sidebar and +// stranding it (via its slide-in transform) so its nav links can't be clicked. +// +// The fix: a deterministic SSR-safe default (`sidebarOpen: true`) plus +// `skipHydration: true`, with AppShell calling `persist.rehydrate()` after mount. + +const STORE_KEY = "darkbloom-store"; + +describe("store SSR-safe hydration (regression: nav unclickable / #418)", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + }); + + it("initializes sidebarOpen to a deterministic true, independent of window.innerWidth", async () => { + // The old code returned `window.innerWidth >= 640`. Force a small viewport to + // prove the initial value no longer depends on it (otherwise the server, + // which has no window, would render a different sidebar than the client). + Object.defineProperty(window, "innerWidth", { + value: 320, + configurable: true, + writable: true, + }); + + const { useStore } = await import("@/lib/store"); + expect(useStore.getState().sidebarOpen).toBe(true); + }); + + it("does not apply persisted state until rehydrate() runs (skipHydration)", async () => { + // Persist values that differ from the SSR defaults. With synchronous + // rehydration (the bug), these would be applied during module init so the + // first client render diverges from the server HTML. + localStorage.setItem( + STORE_KEY, + JSON.stringify({ + state: { + sidebarOpen: false, + chats: [{ id: "c1", title: "Saved chat", messages: [], createdAt: 0 }], + activeChatId: "c1", + selectedModel: "saved-model", + useMyMachine: true, + }, + version: 0, + }) + ); + + const { useStore } = await import("@/lib/store"); + + // First-render state MUST equal the in-code defaults so it matches the + // server render. (Pre-fix this fails: persist applies the stored values now.) + expect(useStore.getState().sidebarOpen).toBe(true); + expect(useStore.getState().chats).toEqual([]); + expect(useStore.getState().selectedModel).toBe(""); + + // AppShell triggers this on mount — only then is persisted state applied. + await useStore.persist.rehydrate(); + expect(useStore.getState().sidebarOpen).toBe(false); + expect(useStore.getState().chats).toHaveLength(1); + expect(useStore.getState().selectedModel).toBe("saved-model"); + }); + + it("still writes state changes back to localStorage after the fix", async () => { + const { useStore } = await import("@/lib/store"); + useStore.getState().setSidebarOpen(false); + + const raw = localStorage.getItem(STORE_KEY); + expect(raw).toBeTruthy(); + expect(JSON.parse(raw as string).state.sidebarOpen).toBe(false); + }); +}); diff --git a/console-ui/src/components/AppShell.tsx b/console-ui/src/components/AppShell.tsx index 5bbecdaa..c8e3a613 100644 --- a/console-ui/src/components/AppShell.tsx +++ b/console-ui/src/components/AppShell.tsx @@ -1,13 +1,29 @@ "use client"; +import { useEffect } from "react"; import { usePathname } from "next/navigation"; import { Sidebar } from "./Sidebar"; import { Toasts } from "./Toasts"; import { ProviderSlackPopup } from "./community/ProviderSlackPopup"; +import { useStore, STORE_NAME } from "@/lib/store"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + // The store uses `skipHydration` so the first client render matches the server + // (no React #418 hydration mismatch). Now that we're mounted, restore the + // persisted state, then apply the responsive sidebar default for first-time + // visitors on small screens (where the sidebar is a full-screen overlay). + useEffect(() => { + const firstVisit = + typeof window !== "undefined" && + window.localStorage.getItem(STORE_NAME) === null; + useStore.persist.rehydrate(); + if (firstVisit && typeof window !== "undefined" && window.innerWidth < 640) { + useStore.getState().setSidebarOpen(false); + } + }, []); + // Device-linking page — no shell if (pathname === "/link") { return <>{children}; diff --git a/console-ui/src/components/InviteCodeBanner.tsx b/console-ui/src/components/InviteCodeBanner.tsx index 376f05c6..16c87b2e 100644 --- a/console-ui/src/components/InviteCodeBanner.tsx +++ b/console-ui/src/components/InviteCodeBanner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Ticket, X, Check, Loader2 } from "lucide-react"; import { redeemInviteCode } from "@/lib/api"; import { trackEvent } from "@/lib/google-analytics"; @@ -11,10 +11,14 @@ export const INVITE_DISMISSED_EVENT = "darkbloom-invite-dismissed"; const DISMISSED_KEY = INVITE_DISMISSED_KEY; export function InviteCodeBanner() { - const [dismissed, setDismissed] = useState(() => { - if (typeof window === "undefined") return true; - return localStorage.getItem(DISMISSED_KEY) === "1"; - }); + // SSR-safe: render nothing on the first client paint (matching the server, + // which has no localStorage) to avoid a React #418 hydration mismatch that + // would regenerate the page tree and break the sidebar nav. The real dismissed + // state is read after mount. + const [dismissed, setDismissed] = useState(true); + useEffect(() => { + setDismissed(localStorage.getItem(DISMISSED_KEY) === "1"); + }, []); const [expanded, setExpanded] = useState(false); const [code, setCode] = useState(""); const [loading, setLoading] = useState(false); diff --git a/console-ui/src/lib/store.ts b/console-ui/src/lib/store.ts index 86a952c3..5945d4b3 100644 --- a/console-ui/src/lib/store.ts +++ b/console-ui/src/lib/store.ts @@ -2,6 +2,10 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { TrustMetadata, Model } from "./api"; +// localStorage key for the persisted store. Exported so the app shell can detect +// a first-time visitor (no persisted state yet) when applying responsive defaults. +export const STORE_NAME = "darkbloom-store"; + export interface Chat { id: string; title: string; @@ -65,7 +69,13 @@ export const useStore = create()( activeChatId: null, selectedModel: "", models: [], - sidebarOpen: typeof window !== "undefined" ? window.innerWidth >= 640 : true, + // Deterministic SSR-safe default: the server always renders the sidebar + // open, so the first client render must match (reading window.innerWidth + // here diverges on mobile → React #418 hydration mismatch, which + // regenerates the tree and breaks the sidebar's event handlers). The + // responsive default + any persisted value are applied after mount (see + // AppShell's rehydrate effect and `skipHydration` below). + sidebarOpen: true, useMyMachine: false, createChat: () => { @@ -166,7 +176,14 @@ export const useStore = create()( })), }), { - name: "darkbloom-store", + name: STORE_NAME, + // Defer reading persisted state until after mount. With the default + // (synchronous) rehydration, persisted values (chats, sidebarOpen, + // selectedModel, …) are applied during the first client render and diverge + // from the server HTML → React hydration mismatch (#418) → the whole tree + // (including the freshly-SSR'd sidebar) is regenerated and loses its click + // handlers. AppShell calls `useStore.persist.rehydrate()` once mounted. + skipHydration: true, partialize: (state) => ({ chats: state.chats.map((c) => ({ ...c,