Skip to content
Merged
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
78 changes: 78 additions & 0 deletions console-ui/__tests__/store-hydration.test.ts
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);
});
});
Comment on lines +1 to +78

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.

🔵 [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

16 changes: 16 additions & 0 deletions console-ui/src/components/AppShell.tsx
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

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.

🟡 [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

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.

🟡 [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}</>;
Expand Down
14 changes: 9 additions & 5 deletions console-ui/src/components/InviteCodeBanner.tsx
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";
Expand All @@ -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

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.

🔵 [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);
Expand Down
21 changes: 19 additions & 2 deletions console-ui/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

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.

🔵 [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: () => {
Expand Down Expand Up @@ -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,
Expand Down
Loading