[codex] Fix usePathname hydration snapshot#878
Conversation
|
/bigbonk review please |
commit: |
|
There was a problem hiding this comment.
Review — usePathname hydration snapshot fix
Reviewed the change in packages/vinext/src/shims/navigation.ts plus the regression test in tests/shims.test.ts. Verified behavior by running the new test against both main (fails with <span>/||</span>) and the PR branch (passes with <span>/split-hydration|hello|hello</span>). Also re-ran tests/nextjs-compat/use-client-page-pathname.test.ts + tests/nextjs-compat/rsc-context-lazy-stream.test.ts — all green.
Summary — looks correct
The root cause is real and this fix addresses it. On the client, usePathname() / useSearchParams() / useParams() call useSyncExternalStore with _getServerContext() as the getServerSnapshot. React uses that function during the hydration render to match the SSR-rendered HTML.
Before this PR, that getServerSnapshot path on the client had no globalThis bridge:
navigation-state.ts(the Symbol.for globalThis writer for issue #688) is server-only — it importsnode:async_hooks, so it never runs in the browser.- The browser entry’s
restoreHydrationNavigationContext()→setNavigationContext(...)calls only updated_serverContexton whichever module instance ofnavigation.tsthe browser entry resolved. "use client"components could get a different module instance ofnavigation.tsunder Vite (different resolved ID / env), and that instance’s_serverContextwas stillnull→ fallback to"/"→ hydration mismatch (exactly what #871 reports).
Moving the hydration snapshot onto globalThis[Symbol.for("vinext.navigation.clientHydrationContext")] gives every client module instance of the shim a shared view, mirroring the existing GLOBAL_ACCESSORS_KEY pattern that already solves the server side (navigation.ts:145-153, navigation-state.ts:128-129). Good fix, minimal surface area.
Correctness walk-through
Server behavior is unchanged. navigation-state.ts replaces _setServerContext / _getServerContext via _registerStateAccessors() at import time (navigation-state.ts:128). Once that’s done the function bodies with the new typeof window !== "undefined" branch are never executed on the server — ALS is the live code path. The defaults’ client-only fast path is therefore unreachable during SSR and cannot leak state across requests or break ALS isolation. Verified by re-running the pathname / lazy-stream compat tests.
Client behavior is updated as intended.
_setServerContexton the client sets both the module-local_serverContextand the global snapshot (navigation.ts:204-216). Setting the local as well is nice belt-and-suspenders, and it also keepshasOwnProperty-based reads from the same module instance working._getServerContextprefers the global snapshot when it has been explicitly set (includingnull), otherwise falls back to the module-local_serverContext(navigation.ts:196-203). TheObject.prototype.hasOwnProperty.call(...)check is the right choice to distinguish "never set" from "explicitly null" — without that,setNavigationContext(null)on the client followed by a hook read would silently hand back a stale context from_serverContext.
Staleness is not an issue. useSyncExternalStore only consults getServerSnapshot during the SSR / hydration initial render. Post-hydration renders use getSnapshot (the client store), and subsequent client navigations go through getPathnameSnapshot() / getSearchParamsSnapshot() / getClientParamsSnapshot() which read ClientNavigationState.cachedPathname etc. The hydration snapshot is therefore allowed to stay "stuck" at the initial URL indefinitely — React won’t read it again for that page.
getNavigationContext() consumers are all server-side. I grep’d — getNavigationContext is consumed by app-rsc-entry.ts, app-page-render.ts, app-page-boundary-render.ts only. None of them run in the browser, so changing the client fallback doesn’t alter any existing code path.
No cleanup concern. restoreHydrationNavigationContext in the browser entry only ever passes a populated NavigationContext; nothing in the browser calls setNavigationContext(null). So the hydrationContext !== undefined ? hydrationContext : _serverContext branch never ends up intentionally poisoning reads with an explicit null.
Test quality
The regression test genuinely exercises the split-module case:
- Loads the shim under two distinct query strings so ESM caches two separate module records (the setter instance and the hook instance).
- Clears both
GLOBAL_ACCESSORS_KEYand the new hydration key beforehand to prevent interference from other tests, and restores prior values infinally. - Mocks
windowsoisServerevaluates false inside the shim and the default_setServerContexttakes the new client branch. - Uses
renderToStaticMarkup, which drivesuseSyncExternalStorethrough itsgetServerSnapshotargument — the exact code path that returns"/"during browser hydration in #871.
Confirmed by inverting the diff locally: without the shim change the test renders <span>/||</span>; with it, <span>/split-hydration|hello|hello</span>.
Minor suggestions (non-blocking)
-
Comment clarifying the "why" of the client branch. The existing inline comment above
GLOBAL_HYDRATION_CONTEXT_KEYis good, but thetypeof windowcheck inside_getServerContext/_setServerContextreads a little odd on a default that’s "supposed to be overridden on the server". A single-line reminder like// Hit only on the client — navigation-state.ts replaces both setters on the server.atnavigation.ts:197and:205would help future readers not worry about the server branch leaking through this path. -
Consider covering
setNavigationContext(null)on the client explicitly. It’s not reachable today, but if a future refactor ever starts callingsetNavigationContext(null)from browser code the fallback-to-stale-module-local vs explicit-null semantics become subtle. A tiny unit test asserting that aftersetNavigationContext(null)on module A the hook module B readsnull(not a stale context from its own_serverContext) would lock in thehasOwnPropertycontract. -
_getClientHydrationContextreturn simplification.return globalState[_GLOBAL_HYDRATION_CONTEXT_KEY] ?? nullcoupled with anhasOwnPropertyguard is fine, but the?? nullis a no-op when the property exists — stored values areNavigationContext | null, neverundefined. You could justreturn globalState[_GLOBAL_HYDRATION_CONTEXT_KEY] as NavigationContext | nullwithout changing behavior. Cosmetic only. -
Next.js parity note. The PR comment in the test correctly points at
app-router.tsx’sPathnameContext. Worth noting in the shim comment too that Next.js sidesteps this class of module-split bug by keeping hydration values inside a React context the provider tree carries across module instances; vinext’s shim usesuseSyncExternalStoreinstead and so has to bridge the snapshot viaglobalThis. That context innavigation.ts:166-168is the main place a future reader will look to understand why this workaround exists.
Verdict
Approve. Fix is minimal, targeted, correct on both client and server, and the regression test is a real regression test (I confirmed it fails without the shim change). No dev/prod parity concerns — the client-only branch is gated by typeof window !== "undefined" and server ALS overrides take precedence via _registerStateAccessors.
|
Review posted on PR #878. Summary of review:
|
|
@james-elicx not approved here. I think it forgot how to approve PR |
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Approving on the basis of the detailed review above — verdict there was "Approve" but it was submitted as a plain comment rather than an approval. Re-submitting now as a formal approval.
Recap of that review:
- Root cause confirmed: on the client,
useSyncExternalStore'sgetServerSnapshot(_getServerContext()?.pathname ?? "/") had no globalThis bridge becausenavigation-state.tsis server-only (importsnode:async_hooks). Separate Vite module instances of the shim meant the browser entry'ssetNavigationContext(...)populated only one instance while hook callers read"/"from their own module-local_serverContext. - Fix is well-scoped: uses
Symbol.forglobalThis bridge, mirroring the existingGLOBAL_ACCESSORS_KEYpattern from #688. Server ALS is unchanged because_registerStateAccessorsreplaces both default functions before thetypeof windowbranch can run. - Verified by inversion: new regression test fails on
main(<span>/||</span>) and passes on this branch (<span>/split-hydration|hello|hello</span>). Related compat tests (use-client-page-pathname,rsc-context-lazy-stream) stay green. - Nits listed in the prior review remain non-blocking.
|
Formal approval posted on PR #878. What I did:
Review state on PR #878 is now |
Fixes #871.
Summary
Symbol.forglobal so splitnext/navigationmodule instances read the same initial snapshot.usePathname(),useSearchParams(), anduseParams()from falling back to/during hydration.Validation
pnpm test tests/shims.test.ts -t "shares the hydrated navigation snapshot"pnpm test tests/nextjs-compat/use-client-page-pathname.test.ts tests/nextjs-compat/rsc-context-lazy-stream.test.ts