Skip to content

fix(console-ui): sidebar nav unclickable — fix React #418 hydration mismatch#457

Merged
Gajesh2007 merged 1 commit into
masterfrom
fix/console-ui-nav-unclickable
Jun 23, 2026
Merged

fix(console-ui): sidebar nav unclickable — fix React #418 hydration mismatch#457
Gajesh2007 merged 1 commit into
masterfrom
fix/console-ui-nav-unclickable

Conversation

@Gajesh2007

@Gajesh2007 Gajesh2007 commented Jun 23, 2026

Copy link
Copy Markdown
Member

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 its slide-in transform — leaving the sidebar stranded at translateX(-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 at transform: matrix(1,0,0,1,-260,0) / opacity:0.
  • The dev server relayed Uncaught Error: Hydration failed … pointing at InviteCodeBanner reading localStorage in a useState initializer.

Root cause

Two client-only reads diverged the first client render from the server HTML:

  1. console-ui/src/lib/store.tssidebarOpen was seeded from window.innerWidth >= 640, and zustand's persist middleware 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.
  2. console-ui/src/components/InviteCodeBanner.tsx — read localStorage in its useState initializer, so the server rendered null while 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: default sidebarOpen: true (no window read) + skipHydration: true (persist no longer rehydrates synchronously; writes still persist).
  • AppShell.tsx: call useStore.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: initialize dismissed: true; read localStorage in 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 after rehydrate()).

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
  end
Loading

Verification

  • Browser (dev, localhost:3000): no hydration error in the console on / and /stats; elementFromPoint over 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

  • Returning user with persisted chats / sidebarOpen=false: sidebar renders and every nav item is clickable; no #418 in console.
  • First-time visitor on mobile (<640px): sidebar starts collapsed (full-screen overlay), opens via the hamburger.
  • Desktop: sidebar slide-in plays once and lands on-screen; Chat/Stats/Providers/Earn/API/Models/Billing/Settings all navigate.
  • Theme toggle, toasts, invite-code banner, and provider Slack popup still work.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

…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>
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
d-inference Ready Ready Preview Jun 23, 2026 11:17pm
d-inference-console-ui-dev Ready Ready Preview Jun 23, 2026 11:17pm
d-inference-landing Ready Ready Preview Jun 23, 2026 11:17pm

Request Review

@ethenotethan ethenotethan left a comment

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.

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

Comment on lines +17 to +24
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);
}

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 +1 to +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);
});
});

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

Comment on lines +17 to +25
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);
}
}, []);

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

Comment on lines +72 to +78
// 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,

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

Comment on lines +14 to +21
// 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");
}, []);

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

@github-actions

Copy link
Copy Markdown

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

  • TB-004 — Browser ↔ Coordinator (console-ui localStorage / Zustand persistence)

Threat analysis

Threat Assessment
T-018 — Chat history persists in localStorage after logout (SEC-024) ℹ️ Neutral. skipHydration: true defers reading the persisted store until after mount; it does not change what is written to localStorage or when it is cleared. Chat history still accumulates in darkbloom-store and is still not wiped on logout. SEC-024 remains fully open.

New observations

1. STORE_NAME export widens the attack surface slightly (minor)

store.ts now exports STORE_NAME = "darkbloom-store" so AppShell.tsx can check for first-time visitors (localStorage.getItem(STORE_NAME)). This is the same key that holds chat messages and prior prompts (the SEC-024 asset). The export itself is not a vulnerability, but any future caller of STORE_NAME that writes, reads, or clears the key becomes security-relevant and should be reviewed against T-018. The three unchecked files (AppShell.tsx, store-hydration.test.ts, InviteCodeBanner.tsx) were not included in this diff — the reviewer should confirm that AppShell.tsx's use of STORE_NAME neither leaks chat content nor creates a new path that bypasses the logout-clear logic (which currently doesn't clear chat state anyway).

2. skipHydration: true + manual rehydrate() call is now the logout-clear window

With synchronous hydration, there was exactly one place where persisted state entered the live store. With skipHydration: true, the useStore.persist.rehydrate() call in AppShell becomes the sole injection point. If a future change moves or conditionally skips that call (e.g. on a route that doesn't mount AppShell), the store would start blank in that context — but the underlying localStorage data would still be present and readable. This is not an immediate issue, but the rehydrate() call site in AppShell.tsx should be treated as security-load-bearing for T-018 if a logout-clear is ever added there.


Open findings resolved

None — this PR does not resolve any SEC-* finding.


🔐 Threat model: docs/threat-model.yaml · Updates on each push to this PR

@Gajesh2007 Gajesh2007 merged commit e8ba9c0 into master Jun 23, 2026
30 of 32 checks passed
@blacksmith-sh

blacksmith-sh Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
github.com/eigeninference/d-inference/e2e/TestIntegration_ConcurrentRequests View Logs

Fix with Codesmith
Need help on this PR? Tag /codesmith with what you need.

anupsv added a commit that referenced this pull request Jun 24, 2026
…-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).
anupsv added a commit that referenced this pull request Jun 24, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants