fix(console-ui): sidebar nav unclickable — fix React #418 hydration mismatch#457
Conversation
…kable The left-nav links rendered but did nothing — clicking Chat/Stats/etc. never navigated. Root cause is a React hydration mismatch (#418), not an overlay or a z-index/stacking bug. Two client-only reads ran during the first (hydration) render, so the first client render diverged from the server HTML: - store.ts seeded `sidebarOpen` from `window.innerWidth` and let zustand's `persist` middleware rehydrate synchronously on load, so any persisted value (sidebarOpen, chats, selectedModel) or a sub-640px viewport diverged from the server, which always renders the sidebar open with empty chats. - InviteCodeBanner read `localStorage` in its `useState` initializer, so the server rendered nothing while the client rendered the banner. On a mismatch React discards the server DOM and regenerates the tree on the client. That re-mounts the freshly-SSR'd <aside> sidebar, replaying its `slide-in` transform and stranding it at translateX(-100%) — present in the DOM but off-screen, so its links can't be hit (confirmed via document.elementFromPoint over the Stats link). Fix — make the first client render deterministic so it matches the server: - store.ts: default `sidebarOpen: true` (no window read) + `skipHydration: true`. AppShell now calls `useStore.persist.rehydrate()` on mount and reapplies the responsive (mobile-collapsed) default for first-time visitors, so persisted state loads after hydration instead of during it. - InviteCodeBanner: initialize `dismissed: true`; read localStorage in a mount effect. Adds __tests__/store-hydration.test.ts (fails without the fix): asserts the deterministic initial state and that persisted values only apply after rehydrate(). Verified in-browser: no hydration error on / or /stats, elementFromPoint over the Stats link returns the link, and clicking Stats navigates to /stats. npm run build, eslint src/, and vitest all pass. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
ethenotethan
left a comment
There was a problem hiding this comment.
Automated Code Review — Layr-Labs/d-inference#
Verdict: REQUEST_CHANGES
Security — ✅ No issues found
Performance — 1 finding(s) (1 blocking)
- 🟡 [MEDIUM]
console-ui/src/components/AppShell.tsx:17-24— 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
Type_diligence — ✅ No issues found
Additive_complexity — 4 finding(s) (1 blocking)
- 🔵 [INFO]
console-ui/__tests__/store-hydration.test.ts:1-78— 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.
- 🟡 [MEDIUM]
console-ui/src/components/AppShell.tsx:17-25— 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.
- 🔵 [INFO]
console-ui/src/lib/store.ts:72-78— Long comment explaining SSR-safe default could be simplified- Suggestion: Reduce comment to: '// SSR-safe default: matches server render, responsive value applied after mount'
- 🔵 [INFO]
console-ui/src/components/InviteCodeBanner.tsx:14-21— 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.
5 finding(s) total, 2 blocking. Verdict: REQUEST_CHANGES.
🤖 Automated review by Centaur · DAR-186
| 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); | ||
| } |
There was a problem hiding this comment.
🟡 [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
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🔵 [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
| 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); | ||
| } | ||
| }, []); |
There was a problem hiding this comment.
🟡 [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
| // 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, |
There was a problem hiding this comment.
🔵 [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
| // 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"); | ||
| }, []); |
There was a problem hiding this comment.
🔵 [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
|
This PR makes a hydration correctness fix to the Zustand store; it does not introduce or remediate any security controls, but two minor security-relevant observations apply. Trust boundaries touched
Threat analysis
New observations1.
2. With synchronous hydration, there was exactly one place where persisted state entered the live store. With Open findings resolvedNone — this PR does not resolve any SEC-* finding. 🔐 Threat model: |
|
Found 1 test failure on Blacksmith runners: Failure
|
…-install tabs (#462) The provider dashboard tabs (Dashboard/Setup/Earnings) and the onboarding "Set up a provider" / "Open calculator" buttons used next/link, whose client router is currently broken app-wide (the #457/#458 regression): the links render but router.push silently no-ops, so clicking them never switched pages. #458 converted only the sidebar to native <a> and explicitly left the broken Link path in place; these dashboard surfaces were never converted. - Switch the providers tabs, onboarding CTAs, and the dashboard fix-affordance internal links to native <a> navigation (browser-native route loads), the same workaround #458 used for the sidebar. - Gate the Setup/Earnings tabs behind a one-shot "has linked providers" check so they appear only after a machine is linked; pre-install shows just the Dashboard, whose onboarding state owns the setup flow. The check starts false and reads state only inside an effect, so the first render is deterministic (no hydration mismatch).
…tion (root cause) (#463) VerificationModeProvider — added in #450 and wrapping the entire app — read localStorage in its useState initializer, so the first client render diverged from the server HTML whenever the user had toggled "technical" mode. On a React hydration mismatch the server DOM is discarded and the tree is regenerated on the client, which breaks App Router client navigation app-wide: next/link renders but router.push silently no-ops (links don't switch pages). This is the regression #457 chased — it made the store and InviteCodeBanner hydration-deterministic but missed this provider — and #458 then band-aided by switching the sidebar to native <a>. Fix: start "normal" on the server and the first client render, then load the persisted preference in an effect (the same hydration-determinism pattern as #457). Adds a regression test pinning the deterministic first render. This is the root-cause fix for the broken Link client router; the native-<a> workarounds (#458 sidebar, #462 provider tabs) can be reverted in a follow-up once verified on a preview deploy.

Summary
The left-nav sidebar (Chat / Stats / Provider Dashboard / Earn / API / Models / Billing / Settings) rendered but was completely unclickable — clicking a nav item did nothing and the URL never changed. Login (Privy modal) worked fine.
This was not an overlay or a z-index / stacking bug. The true cause is a React hydration mismatch (#418): client-only state was read during the first (hydration) render, so the first client render diverged from the server HTML. React then threw away the server DOM and regenerated the tree, which re-mounted the freshly-SSR'd
<aside>and replayed itsslide-intransform — leaving the sidebar stranded attranslateX(-100%), present in the DOM but off-screen, so its links couldn't be hit.Confirmed in-browser:
document.elementFromPoint(centerOfStatsLink)returned nothing while the<aside>sat attransform: matrix(1,0,0,1,-260,0)/opacity:0.Uncaught Error: Hydration failed …pointing atInviteCodeBannerreadinglocalStoragein auseStateinitializer.Root cause
Two client-only reads diverged the first client render from the server HTML:
console-ui/src/lib/store.ts—sidebarOpenwas seeded fromwindow.innerWidth >= 640, and zustand'spersistmiddleware rehydrated synchronously on load. So any persisted value (sidebarOpen,chats,selectedModel) — or a sub-640px viewport — made the first client render differ from the server (which always renders the sidebar open with empty chats). This is environment-independent and hits returning users in prod.console-ui/src/components/InviteCodeBanner.tsx— readlocalStoragein itsuseStateinitializer, so the server renderednullwhile the client rendered the banner (the confirmed dev-console error).Fix
Make the first client render deterministic so it matches the server; apply client-only values after mount.
store.ts: defaultsidebarOpen: true(nowindowread) +skipHydration: true(persist no longer rehydrates synchronously; writes still persist).AppShell.tsx: calluseStore.persist.rehydrate()on mount, then reapply the responsive (mobile-collapsed) default for first-time visitors — so persisted state and viewport logic run after hydration instead of during it.InviteCodeBanner.tsx: initializedismissed: true; readlocalStoragein a mount effect.__tests__/store-hydration.test.ts: regression test that fails without the fix (asserts deterministic initial state and that persisted values only apply afterrehydrate()).Behavior is preserved: persisted sidebar/chat state still restores, the mobile sidebar overlay still starts collapsed for new visitors, theme toggle / toasts / popups are untouched — they just reconcile one frame after mount (the correct SSR-safe pattern).
Before / After
flowchart TB subgraph BEFORE["Before — hydration mismatch breaks the nav"] direction TB B1["SSR renders sidebar OPEN (aside present)<br/>InviteCodeBanner = null"] B2["Client FIRST render DIVERGES from server:<br/>store reads window.innerWidth + persist<br/>rehydrates synchronously;<br/>InviteCodeBanner reads localStorage"] B3["React #418 hydration mismatch:<br/>discard server DOM, regenerate tree on client"] B4["aside re-mounts -> slide-in replays<br/>-> stuck at translateX(-100%), opacity 0 (off-screen)"] B5["Click Stats -> nothing happens<br/>(elementFromPoint = null)"] B1 --> B2 --> B3 --> B4 --> B5 end subgraph AFTER["After — deterministic first render"] direction TB A1["SSR renders sidebar OPEN<br/>InviteCodeBanner = null"] A2["Client FIRST render MATCHES server:<br/>sidebarOpen=true (no window read),<br/>skipHydration defers persist,<br/>InviteCodeBanner dismissed=true -> null"] A3["Hydration succeeds (no #418):<br/>adopt server DOM in place, bind handlers"] A4["AppShell useEffect:<br/>persist.rehydrate() + mobile default (after mount)"] A5["slide-in completes -> translateX(0) (on-screen)"] A6["Click Stats -> navigates to /stats<br/>(elementFromPoint = a[href=/stats])"] A1 --> A2 --> A3 --> A4 --> A5 --> A6 endVerification
/and/stats;elementFromPointover the Stats link returns the<a href="/stats">; clicking Stats navigates to/stats(location.pathname === "/stats", heading "Network Stats").cd console-ui && npm run build— passes (39 routes, no errors).cd console-ui && npx eslint src/— passes (0 errors).cd console-ui && npx vitest run— passes, including the new regression test.Test plan
chats/sidebarOpen=false: sidebar renders and every nav item is clickable; no#418in console.Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.