fix(ssr,dev): seed useNav() path at SSR + fix run({dev:true}) hot reload#92
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two framework-level fixes reported from the ketshopweb studio app.
Bug 1 —
useNav()not active at SSRA native layout renders a React island (
SidebarNav) that derives its active link fromuseNav(). On SSR the active link was never highlighted; it only became active after a client-side navigation.Root cause:
nav.pathis aglobalThissingleton seeded client-side via__navInit, so the server always had path''. AnduseNavpassesgetNavStateas BOTH the client and server snapshot touseSyncExternalStore— so once the client seeded the path (beforehydrateMarkersIn), 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) mirroringstore/server-context.ts— a server-onlyAsyncLocalStoragethat registers a resolver into the client-safestore.tsvia__setServerNavResolver(sonode:async_hooksnever reaches the browser bundle).getNavState()consults the scope first (request path/search,phase: 'idle'), else the singleton.routes.tsopens the scope for every island/React SSR site: React full-doc + SPA-nav payload (viarunInRequestContext's new trailing nav args) and the two native island-SSRPromise.allsites (which run outside the loader scope).renderFragmentgains opt-inpath/search. Client behavior unchanged (server renders the active markup; the client still__navInits fromlocation.pathnamebefore hydration, so both agree → no mismatch, active from first paint).Bug 2 —
run({ dev: true })hot reload crashHot reload under the programmatic
run({ dev: true })path threwworker-registry: spawnAll called before registerInitialPool.serve()'sregisterInitialPoolgated onprocess.env.BRUST_DEV === '1'alone, while the dev coordinator/watcher gated on the broaderdev = opts.dev || envflag — watcher live, pool never registered.run()now setsBRUST_DEV=1when dev is enabled by either signal (what thebrust devCLI already does), unifying everyBRUST_DEV-gated path.Tests
runtime/navigation/server-context.test.ts(7) — scoped path wins over singleton, ALS isolation, canonicalization, referential stability, survives awaitsruntime/navigation/usenav-ssr.test.ts(3) —renderToStringproves the active class appears under the scope, idle without itrenderFragmentpath-seed (2)tests/dev-reload-option.test.ts— proven to fail without the bug-2 fix (gotReload=false)Local baselines: biome
ci277 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