-
Notifications
You must be signed in to change notification settings - Fork 42
fix(console-ui): sidebar nav unclickable — fix React #418 hydration mismatch #457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
|
Comment on lines
+17
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [MEDIUM] ⚡ Synchronous localStorage access in useEffect on every mount 💡 Suggestion: Consider wrapping localStorage.getItem in try-catch and making it async if the operation could be expensive, or move the check outside the effect if it's truly one-time 📊 Score: 2×4 = 8 · Category: Blocking I/O on hot paths |
||
| }, []); | ||
|
Comment on lines
+17
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [MEDIUM] 🧩 AppShell handling store rehydration and responsive logic mixes UI shell with data persistence concerns 💡 Suggestion: Move store rehydration to a custom hook like useStoreHydration() or handle it in the store itself with a mount detection pattern. 📊 Score: 3×4 = 12 · Category: misplaced responsibility |
||
|
|
||
| // Device-linking page — no shell | ||
| if (pathname === "/link") { | ||
| return <>{children}</>; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }, []); | ||
|
Comment on lines
+14
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 SSR-safe localStorage pattern duplicated between InviteCodeBanner and store 💡 Suggestion: Create a shared useLocalStorageSSR hook to avoid repeating the 'start with safe default, read real value after mount' pattern. 📊 Score: 2×3 = 6 · Category: duplicate logic |
||
| const [expanded, setExpanded] = useState(false); | ||
| const [code, setCode] = useState(""); | ||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<AppState>()( | |
| 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, | ||
|
Comment on lines
+72
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Long comment explaining SSR-safe default could be simplified 💡 Suggestion: Reduce comment to: '// SSR-safe default: matches server render, responsive value applied after mount' 📊 Score: 2×2 = 4 · Category: over-configuration |
||
| useMyMachine: false, | ||
|
|
||
| createChat: () => { | ||
|
|
@@ -166,7 +176,14 @@ export const useStore = create<AppState>()( | |
| })), | ||
| }), | ||
| { | ||
| 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 [INFO] 🧩 78-line test file for a simple hydration fix could be much simpler
💡 Suggestion: Reduce to 2-3 focused tests: one for deterministic initial state, one for skipHydration behavior. The current test duplicates store behavior testing that should be unit tests.
📊 Score: 2×3 = 6 · Category: over-abstraction