Skip to content

fix(ssr,dev): seed useNav() path at SSR + fix run({dev:true}) hot reload#92

Merged
Aitthi merged 1 commit into
mainfrom
fix/usenav-ssr-seed-dev-hotreload
Jun 13, 2026
Merged

fix(ssr,dev): seed useNav() path at SSR + fix run({dev:true}) hot reload#92
Aitthi merged 1 commit into
mainfrom
fix/usenav-ssr-seed-dev-hotreload

Conversation

@Aitthi

@Aitthi Aitthi commented Jun 13, 2026

Copy link
Copy Markdown
Member

Two framework-level fixes reported from the ketshopweb studio app.

Bug 1 — useNav() not active at SSR

A native layout renders a React island (SidebarNav) that derives its active link from useNav(). On SSR the active link was never highlighted; it only became active after a client-side navigation.

Root cause: nav.path is a globalThis singleton seeded client-side via __navInit, so the server always had path ''. And useNav passes getNavState as BOTH the client and server snapshot to useSyncExternalStore — so once the client seeded the path (before hydrateMarkersIn), React saw no mismatch and kept the inactive server className until the next navigation.

Fix: a per-request nav scope (runtime/navigation/server-context.ts) mirroring store/server-context.ts — a server-only AsyncLocalStorage that registers a resolver into the client-safe store.ts via __setServerNavResolver (so node:async_hooks never reaches the browser bundle). getNavState() consults the scope first (request path/search, phase: 'idle'), else the singleton. routes.ts opens the scope for every island/React SSR site: React full-doc + SPA-nav payload (via runInRequestContext's new trailing nav args) and the two native island-SSR Promise.all sites (which run outside the loader scope). renderFragment gains opt-in path/search. Client behavior unchanged (server renders the active markup; the client still __navInits from location.pathname before hydration, so both agree → no mismatch, active from first paint).

Bug 2 — run({ dev: true }) hot reload crash

Hot reload under the programmatic run({ dev: true }) path threw worker-registry: spawnAll called before registerInitialPool. serve()'s registerInitialPool gated on process.env.BRUST_DEV === '1' alone, while the dev coordinator/watcher gated on the broader dev = opts.dev || env flag — watcher live, pool never registered. run() now sets BRUST_DEV=1 when dev is enabled by either signal (what the brust dev CLI already does), unifying every BRUST_DEV-gated path.

Tests

  • runtime/navigation/server-context.test.ts (7) — scoped path wins over singleton, ALS isolation, canonicalization, referential stability, survives awaits
  • runtime/navigation/usenav-ssr.test.ts (3) — renderToString proves the active class appears under the scope, idle without it
  • renderFragment path-seed (2)
  • tests/dev-reload-option.test.tsproven to fail without the bug-2 fix (gotReload=false)

Local baselines: biome ci 277 files clean · bun test runtime/ 850/0 · integration 100/0 · native-island-ssr 10/0 · dev-reload (CLI) 2/0. Pure TS — no Rust change.

🤖 Generated with Claude Code

Two framework-level fixes reported from the ketshopweb studio app.

Bug 1 — useNav() reported the idle path '' during SSR, so active-nav was
wrong on first paint and only corrected after a client navigation (a native
layout with a React island SidebarNav). The nav store is a globalThis
singleton seeded client-side via __navInit, and the server never had a path.
Worse, useNav passes getNavState as BOTH the client and server snapshot, so
once the client seeded the path (before hydrateMarkersIn) React saw no
mismatch and kept the inactive server className until the next navigation.

Fix: a per-request nav scope (runtime/navigation/server-context.ts), mirroring
store/server-context.ts — a server-only AsyncLocalStorage that registers a
resolver into the client-safe store.ts via __setServerNavResolver, so
node:async_hooks never reaches the browser bundle. getNavState() consults the
scope first (path/search from the request, phase 'idle'), else the singleton.
routes.ts opens the scope for every island/React SSR site: React full-doc +
SPA-nav payload (via runInRequestContext's new trailing nav args) and the two
native island-SSR Promise.all sites (which run outside the loader scope).
renderFragment gains opt-in path/search. Client behavior is unchanged.

Bug 2 — hot reload under the programmatic run({ dev: true }) path threw
"worker-registry: spawnAll called before registerInitialPool". serve()'s
registerInitialPool gated on process.env.BRUST_DEV==='1' alone, while the dev
coordinator/watcher gated on the broader `dev = opts.dev || env` flag — so the
watcher was live but the pool was never registered. run() now sets BRUST_DEV=1
when dev is enabled by either signal (what `brust dev` CLI already does),
unifying every BRUST_DEV-gated path.

Tests: navigation/server-context.test.ts (7), navigation/usenav-ssr.test.ts
(renderToString proves the active class), renderFragment path-seed (2),
tests/dev-reload-option.test.ts (proven to fail without the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Aitthi Aitthi merged commit 3156d64 into main Jun 13, 2026
7 checks passed
@Aitthi Aitthi deleted the fix/usenav-ssr-seed-dev-hotreload branch June 13, 2026 09:22
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.

1 participant