[PR #355] [MERGED] fix: nav context hydration mismatch (usePathname/useSearchParams React error) #505

Closed
opened 2026-05-06 13:08:26 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/355
Author: @james-elicx
Created: 3/8/2026
Status: Merged
Merged: 3/8/2026
Merged by: @james-elicx

Base: mainHead: j-branch-9


📝 Commits (2)

  • 4e4d567 fix: nav context hydration mismatch
  • de10162 fix: address bonk review — add VINEXT_RSC_NAV type, serialise searchParams as entry pairs

📊 Changes

6 files changed (+473 additions, -26 deletions)

View changed files

📝 packages/vinext/src/global.d.ts (+13 -0)
📝 packages/vinext/src/server/app-dev-server.ts (+23 -2)
tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/[id]/page.tsx (+27 -0)
tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/nav-info.tsx (+33 -0)
tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/page.tsx (+26 -0)
📝 tests/nextjs-compat/rsc-context-lazy-stream.test.ts (+351 -24)

📄 Description

Problem

useSyncExternalStore requires that getServerSnapshot returns the same value the server rendered. For usePathname() and useSearchParams(), the server snapshot reads from the navigation context — populated during RSC rendering from the request URL and passed through to the SSR environment.

The browser entry had no way to know what pathname/searchParams were used on the server. So getServerSnapshot always fell back to "/" and an empty URLSearchParams, regardless of the actual request URL. React would compare this against the SSR-rendered HTML (which correctly reflected the real URL) and throw hydration error #418:

Error: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.

This surfaced whenever a "use client" component called usePathname() or useSearchParams() — for example, any nav bar, breadcrumb, or active-link component.

Fix

Server (generateSsrEntry): embed the navigation context as two <script> tags in <head> during HTML generation:

<script>self.__VINEXT_RSC_NAV__={"pathname":"/some/path","searchParams":{"q":"hello"}}</script>
<script>self.__VINEXT_RSC_PARAMS__={"id":"hello"}</script>

searchParams is serialised as a plain object via Object.fromEntries() (safe to JSON-encode; reconstructed on the client with new URLSearchParams()). Both values are passed through safeJsonStringify to prevent </script> injection via crafted query strings.

Browser entry (generateBrowserEntry): read self.__VINEXT_RSC_NAV__ before calling hydrateRoot() and call setNavigationContext() so the client snapshot matches what the server rendered. All three code paths are covered: progressive chunks (new format), legacy single-blob (__VINEXT_RSC__), and the rare fresh-fetch fallback.

Regression tests

15 new Vitest tests in rsc-context-lazy-stream.test.ts, exercising:

  • __VINEXT_RSC_NAV__ and __VINEXT_RSC_PARAMS__ are present in the HTML
  • The embedded pathname matches the request path and agrees with the SSR-rendered usePathname() output
  • searchParams is {} with no query string; carries real params when present; round-trips correctly through new URLSearchParams()
  • Special characters (<, >, &) in query params are safely encoded by safeJsonStringify
  • Dynamic [id] routes: __VINEXT_RSC_PARAMS__ carries the matched segment value and __VINEXT_RSC_NAV__.pathname is correct
  • Both script tags appear before </head>, guaranteeing they're available before hydrateRoot() is called
  • pathname contains only the path, never the query string

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/355 **Author:** [@james-elicx](https://github.com/james-elicx) **Created:** 3/8/2026 **Status:** ✅ Merged **Merged:** 3/8/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `j-branch-9` --- ### 📝 Commits (2) - [`4e4d567`](https://github.com/cloudflare/vinext/commit/4e4d567f1cc8ac3596e8f95229c251e8b601d287) fix: nav context hydration mismatch - [`de10162`](https://github.com/cloudflare/vinext/commit/de10162c8f342586fd60cea8d69914dfbbac9d11) fix: address bonk review — add __VINEXT_RSC_NAV__ type, serialise searchParams as entry pairs ### 📊 Changes **6 files changed** (+473 additions, -26 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/global.d.ts` (+13 -0) 📝 `packages/vinext/src/server/app-dev-server.ts` (+23 -2) ➕ `tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/[id]/page.tsx` (+27 -0) ➕ `tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/nav-info.tsx` (+33 -0) ➕ `tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/page.tsx` (+26 -0) 📝 `tests/nextjs-compat/rsc-context-lazy-stream.test.ts` (+351 -24) </details> ### 📄 Description Problem `useSyncExternalStore` requires that `getServerSnapshot` returns the **same value** the server rendered. For `usePathname()` and `useSearchParams()`, the server snapshot reads from the navigation context — populated during RSC rendering from the request URL and passed through to the SSR environment. The browser entry had no way to know what pathname/searchParams were used on the server. So `getServerSnapshot` always fell back to `"/"` and an empty `URLSearchParams`, regardless of the actual request URL. React would compare this against the SSR-rendered HTML (which correctly reflected the real URL) and throw **hydration error #418**: ``` Error: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. ``` This surfaced whenever a `"use client"` component called `usePathname()` or `useSearchParams()` — for example, any nav bar, breadcrumb, or active-link component. ### Fix **Server (`generateSsrEntry`):** embed the navigation context as two `<script>` tags in `<head>` during HTML generation: ```html <script>self.__VINEXT_RSC_NAV__={"pathname":"/some/path","searchParams":{"q":"hello"}}</script> <script>self.__VINEXT_RSC_PARAMS__={"id":"hello"}</script> ``` `searchParams` is serialised as a plain object via `Object.fromEntries()` (safe to JSON-encode; reconstructed on the client with `new URLSearchParams()`). Both values are passed through `safeJsonStringify` to prevent `</script>` injection via crafted query strings. **Browser entry (`generateBrowserEntry`):** read `self.__VINEXT_RSC_NAV__` before calling `hydrateRoot()` and call `setNavigationContext()` so the client snapshot matches what the server rendered. All three code paths are covered: progressive chunks (new format), legacy single-blob (`__VINEXT_RSC__`), and the rare fresh-fetch fallback. ### Regression tests 15 new Vitest tests in `rsc-context-lazy-stream.test.ts`, exercising: - `__VINEXT_RSC_NAV__` and `__VINEXT_RSC_PARAMS__` are present in the HTML - The embedded `pathname` matches the request path and agrees with the SSR-rendered `usePathname()` output - `searchParams` is `{}` with no query string; carries real params when present; round-trips correctly through `new URLSearchParams()` - Special characters (`<`, `>`, `&`) in query params are safely encoded by `safeJsonStringify` - Dynamic `[id]` routes: `__VINEXT_RSC_PARAMS__` carries the matched segment value and `__VINEXT_RSC_NAV__.pathname` is correct - Both script tags appear **before `</head>`**, guaranteeing they're available before `hydrateRoot()` is called - `pathname` contains only the path, never the query string --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:08:26 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#505
No description provided.