From 6f4272a1fbab0adf3088cc89799b258a84d0a395 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 25 Mar 2026 17:11:26 -0700 Subject: [PATCH 1/2] fix: usePathname() returns "/" during SSR of "use client" page components (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation shim uses a registration pattern where navigation-state.ts calls _registerStateAccessors() to upgrade navigation.ts from module-level state to ALS-backed state. This only updates the specific module instance that imported navigation-state.ts. In Vite multi-environment dev mode, "use client" components can end up with a separate module instance of navigation.ts (due to pre-bundling or different resolver chains). That instance never receives the ALS-backed accessors and falls back to _serverContext (null), causing usePathname() to return "/". Fix: navigation-state.ts now also stores the accessor functions on globalThis via Symbol.for("vinext.navigation.globalAccessors"). The default accessors in navigation.ts check this global before falling back to module-level state. This ensures all module instances can reach the ALS-backed state regardless of which instance _registerStateAccessors was called on. On the browser (where navigation-state.ts is never imported), the global key does not exist, so the fallback to module-level _serverContext is preserved — which is the correct behavior for client-side hydration. Closes #688 --- packages/vinext/src/shims/navigation-state.ts | 16 ++- packages/vinext/src/shims/navigation.ts | 57 ++++++-- tests/e2e/app-router/routes.spec.ts | 34 +++++ .../use-client-page-pathname/[slug]/page.tsx | 23 ++++ .../app/use-client-page-pathname/page.tsx | 28 ++++ .../use-client-page-pathname.test.ts | 129 ++++++++++++++++++ 6 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/app-basic/app/use-client-page-pathname/[slug]/page.tsx create mode 100644 tests/fixtures/app-basic/app/use-client-page-pathname/page.tsx create mode 100644 tests/nextjs-compat/use-client-page-pathname.test.ts diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts index 41ad99c1f..8827cef7c 100644 --- a/packages/vinext/src/shims/navigation-state.ts +++ b/packages/vinext/src/shims/navigation-state.ts @@ -90,9 +90,18 @@ export function runWithServerInsertedHTMLState(fn: () => T | Promise): T | // --------------------------------------------------------------------------- // Register ALS-backed accessors into navigation.ts +// +// Two registration paths (issue #688): +// 1. _registerStateAccessors — updates the module-level function pointers +// in the same module instance that imported us (the SSR entry's copy). +// 2. globalThis[Symbol.for(...)] — makes the accessors discoverable by ANY +// module instance of navigation.ts, even if Vite created a separate one +// for "use client" components due to pre-bundling or env separation. // --------------------------------------------------------------------------- -_registerStateAccessors({ +const _GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); + +const _accessors = { getServerContext(): NavigationContext | null { return _getState().serverContext; }, @@ -108,4 +117,7 @@ _registerStateAccessors({ clearInsertedHTMLCallbacks(): void { _getState().serverInsertedHTMLCallbacks = []; }, -}); +}; + +_registerStateAccessors(_accessors); +(globalThis as unknown as Record)[_GLOBAL_ACCESSORS_KEY] = _accessors; diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 2e23b5b8c..618845f23 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -122,31 +122,68 @@ type NavigationContextWithReadonlyCache = NavigationContext & { // // On the server: state functions are set by navigation-state.ts at import time. // On the client: _serverContext falls back to null (hooks use window instead). +// +// Global accessor pattern (issue #688): +// Vite's multi-environment dev mode can create separate module instances of +// this file for the SSR entry vs "use client" components. When that happens, +// _registerStateAccessors only updates the SSR entry's instance, leaving the +// "use client" instance with the default (null) fallbacks. +// +// To fix this, navigation-state.ts also stores the accessors on globalThis +// via Symbol.for, and the defaults here check for that global before falling +// back to module-level state. This ensures all module instances can reach the +// ALS-backed state regardless of which instance was registered. // --------------------------------------------------------------------------- +interface _StateAccessors { + getServerContext: () => NavigationContext | null; + setServerContext: (ctx: NavigationContext | null) => void; + getInsertedHTMLCallbacks: () => Array<() => unknown>; + clearInsertedHTMLCallbacks: () => void; +} + +const _GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); +type _GlobalWithAccessors = typeof globalThis & { [_GLOBAL_ACCESSORS_KEY]?: _StateAccessors }; + +function _getGlobalAccessors(): _StateAccessors | undefined { + return (globalThis as _GlobalWithAccessors)[_GLOBAL_ACCESSORS_KEY]; +} + let _serverContext: NavigationContext | null = null; let _serverInsertedHTMLCallbacks: Array<() => unknown> = []; // These are overridden by navigation-state.ts on the server to use ALS. -let _getServerContext = (): NavigationContext | null => _serverContext; +// The defaults check globalThis for cross-module-instance access (issue #688). +let _getServerContext = (): NavigationContext | null => { + const g = _getGlobalAccessors(); + return g ? g.getServerContext() : _serverContext; +}; let _setServerContext = (ctx: NavigationContext | null): void => { - _serverContext = ctx; + const g = _getGlobalAccessors(); + if (g) { + g.setServerContext(ctx); + } else { + _serverContext = ctx; + } +}; +let _getInsertedHTMLCallbacks = (): Array<() => unknown> => { + const g = _getGlobalAccessors(); + return g ? g.getInsertedHTMLCallbacks() : _serverInsertedHTMLCallbacks; }; -let _getInsertedHTMLCallbacks = (): Array<() => unknown> => _serverInsertedHTMLCallbacks; let _clearInsertedHTMLCallbacks = (): void => { - _serverInsertedHTMLCallbacks = []; + const g = _getGlobalAccessors(); + if (g) { + g.clearInsertedHTMLCallbacks(); + } else { + _serverInsertedHTMLCallbacks = []; + } }; /** * Register ALS-backed state accessors. Called by navigation-state.ts on import. * @internal */ -export function _registerStateAccessors(accessors: { - getServerContext: () => NavigationContext | null; - setServerContext: (ctx: NavigationContext | null) => void; - getInsertedHTMLCallbacks: () => Array<() => unknown>; - clearInsertedHTMLCallbacks: () => void; -}): void { +export function _registerStateAccessors(accessors: _StateAccessors): void { _getServerContext = accessors.getServerContext; _setServerContext = accessors.setServerContext; _getInsertedHTMLCallbacks = accessors.getInsertedHTMLCallbacks; diff --git a/tests/e2e/app-router/routes.spec.ts b/tests/e2e/app-router/routes.spec.ts index b5b597fd0..c0a7a0256 100644 --- a/tests/e2e/app-router/routes.spec.ts +++ b/tests/e2e/app-router/routes.spec.ts @@ -168,3 +168,37 @@ test.describe("Client navigation hooks SSR", () => { await expect(page.locator('[data-testid="client-search-q"]')).toHaveText(""); }); }); + +test.describe('"use client" page component with usePathname (issue #688)', () => { + test("usePathname renders correct pathname when page itself is use client", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto(`${BASE}/use-client-page-pathname`); + await waitForHydration(page); + + await expect(page.locator("#client-page-pathname")).toHaveText("/use-client-page-pathname"); + expect(errors.filter((e) => e.includes("Hydration"))).toHaveLength(0); + }); + + test("useSearchParams works on use client page with query string", async ({ page }) => { + await page.goto(`${BASE}/use-client-page-pathname?q=test`); + await waitForHydration(page); + + await expect(page.locator("#client-page-search-q")).toHaveText("test"); + }); + + test("usePathname + useParams on dynamic use client page", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto(`${BASE}/use-client-page-pathname/my-slug`); + await waitForHydration(page); + + await expect(page.locator("#client-page-dynamic-pathname")).toHaveText( + "/use-client-page-pathname/my-slug", + ); + await expect(page.locator("#client-page-dynamic-slug")).toHaveText("my-slug"); + expect(errors.filter((e) => e.includes("Hydration"))).toHaveLength(0); + }); +}); diff --git a/tests/fixtures/app-basic/app/use-client-page-pathname/[slug]/page.tsx b/tests/fixtures/app-basic/app/use-client-page-pathname/[slug]/page.tsx new file mode 100644 index 000000000..79f3eccf5 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-client-page-pathname/[slug]/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { usePathname, useSearchParams, useParams } from "next/navigation"; + +/** + * Dynamic-segment variant of the "use client" page regression fixture. + * + * Exercises useParams() in addition to usePathname() / useSearchParams() + * when the page component itself is a client component. + */ +export default function UseClientPagePathnameSlug() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const params = useParams(); + + return ( +
+ {pathname} + {String(params.slug ?? "")} + {searchParams.toString()} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-client-page-pathname/page.tsx b/tests/fixtures/app-basic/app/use-client-page-pathname/page.tsx new file mode 100644 index 000000000..8d862a377 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-client-page-pathname/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; + +/** + * Regression fixture for issue #688. + * + * The page component itself is "use client" and calls usePathname() / + * useSearchParams(). This exercises a different import chain than a + * Server Component page rendering a "use client" child — the page module + * is resolved as a client reference by the RSC environment and rendered + * entirely in the SSR environment. + * + * Without the fix, usePathname() returns "/" during SSR (instead of the + * actual request pathname), causing a React hydration mismatch. + */ +export default function UseClientPagePathname() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return ( +
+ {pathname} + {searchParams.get("q") ?? ""} + {searchParams.toString()} +
+ ); +} diff --git a/tests/nextjs-compat/use-client-page-pathname.test.ts b/tests/nextjs-compat/use-client-page-pathname.test.ts new file mode 100644 index 000000000..233d00d64 --- /dev/null +++ b/tests/nextjs-compat/use-client-page-pathname.test.ts @@ -0,0 +1,129 @@ +/** + * Regression test for issue #688: usePathname() returns "/" during SSR when + * the page component itself is a "use client" component. + * + * Unlike the nav-context-hydration fixture (where the page is a Server + * Component rendering a "use client" child), this fixture has the page + * component itself marked as "use client". This exercises a different module + * resolution path in Vite's SSR environment — the page module is resolved as + * a client reference by the RSC entry and rendered entirely in the SSR module + * runner. + * + * Without the fix, usePathname() falls back to "/" because the "use client" + * page's module instance of navigation.ts may not have the ALS-backed state + * accessors registered (they were only registered on the SSR entry's instance). + * + * Fixture: tests/fixtures/app-basic/app/use-client-page-pathname/ + * page.tsx — "use client" page: usePathname(), useSearchParams() + * [slug]/page.tsx — "use client" dynamic page: + useParams() + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers.js"; + +let _server: ViteDevServer; +let _baseUrl: string; + +const ROUTE = "/use-client-page-pathname"; + +beforeAll(async () => { + ({ server: _server, baseUrl: _baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + + // Warm up so first test doesn't pay cold-start cost. + await fetch(`${_baseUrl}${ROUTE}`); +}, 60_000); + +afterAll(async () => { + await _server?.close(); +}); + +// ── Static route: "use client" page with usePathname() ──────────────────── + +describe('"use client" page component: usePathname() SSR (issue #688)', () => { + it("SSR HTML contains correct pathname (not /)", async () => { + const res = await fetch(`${_baseUrl}${ROUTE}`); + expect(res.status).toBe(200); + const html = await res.text(); + + // The page component calls usePathname() and renders it into #client-page-pathname. + // During SSR, this must be the actual request pathname, not "/". + expect(html).toContain(`${ROUTE}`); + }); + + it("__VINEXT_RSC_NAV__ pathname matches SSR-rendered usePathname()", async () => { + const res = await fetch(`${_baseUrl}${ROUTE}`); + const html = await res.text(); + + const match = html.match(/self\.__VINEXT_RSC_NAV__=(\{[^<]+\})/); + expect(match, "__VINEXT_RSC_NAV__ script tag not found").toBeTruthy(); + const nav = JSON.parse(match![1]); + + expect(nav.pathname).toBe(ROUTE); + }); + + it("SSR HTML contains correct searchParams with query string", async () => { + const res = await fetch(`${_baseUrl}${ROUTE}?q=hello&page=2`); + const html = await res.text(); + + expect(html).toContain('hello'); + expect(html).toContain('q=hello&page=2'); + }); + + it("__VINEXT_RSC_NAV__ searchParams matches query string", async () => { + const res = await fetch(`${_baseUrl}${ROUTE}?q=test`); + const html = await res.text(); + + const match = html.match(/self\.__VINEXT_RSC_NAV__=(\{[^<]+\})/); + expect(match).toBeTruthy(); + const nav = JSON.parse(match![1]); + const sp = new URLSearchParams(nav.searchParams); + + expect(sp.get("q")).toBe("test"); + }); +}); + +// ── Dynamic route: "use client" page with useParams() ───────────────────── + +describe('"use client" dynamic page: usePathname() + useParams() SSR', () => { + const dynamicPath = `${ROUTE}/my-slug`; + + it("SSR HTML contains correct pathname for dynamic route", async () => { + const res = await fetch(`${_baseUrl}${dynamicPath}`); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(html).toContain(`${dynamicPath}`); + }); + + it("SSR HTML contains correct slug param", async () => { + const res = await fetch(`${_baseUrl}${dynamicPath}`); + const html = await res.text(); + + expect(html).toContain('my-slug'); + }); + + it("__VINEXT_RSC_PARAMS__ contains slug for dynamic route", async () => { + const res = await fetch(`${_baseUrl}${dynamicPath}`); + const html = await res.text(); + + const match = html.match(/self\.__VINEXT_RSC_PARAMS__=(\{[^<]*\})/); + expect(match, "__VINEXT_RSC_PARAMS__ script tag not found").toBeTruthy(); + const params = JSON.parse(match![1]); + + expect(params.slug).toBe("my-slug"); + }); + + it("__VINEXT_RSC_NAV__ pathname is correct for dynamic route", async () => { + const res = await fetch(`${_baseUrl}${dynamicPath}`); + const html = await res.text(); + + const match = html.match(/self\.__VINEXT_RSC_NAV__=(\{[^<]+\})/); + expect(match).toBeTruthy(); + const nav = JSON.parse(match![1]); + + expect(nav.pathname).toBe(dynamicPath); + }); +}); From 978c48c2477b2bddec3a7bdab7488ed92f6d4e94 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 26 Mar 2026 09:01:02 -0700 Subject: [PATCH 2/2] refactor: deduplicate global accessor key and tighten types - Export GLOBAL_ACCESSORS_KEY from navigation.ts and import in navigation-state.ts to prevent silent drift if the Symbol string ever changes - Add satisfies constraint on the accessor object so shape mismatches are caught at compile time - Assert warm-up fetch status in test to surface fixture setup failures --- packages/vinext/src/shims/navigation-state.ts | 6 +++--- packages/vinext/src/shims/navigation.ts | 3 ++- tests/nextjs-compat/use-client-page-pathname.test.ts | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts index 8827cef7c..1931d4e10 100644 --- a/packages/vinext/src/shims/navigation-state.ts +++ b/packages/vinext/src/shims/navigation-state.ts @@ -99,7 +99,7 @@ export function runWithServerInsertedHTMLState(fn: () => T | Promise): T | // for "use client" components due to pre-bundling or env separation. // --------------------------------------------------------------------------- -const _GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); +import { GLOBAL_ACCESSORS_KEY } from "./navigation"; const _accessors = { getServerContext(): NavigationContext | null { @@ -117,7 +117,7 @@ const _accessors = { clearInsertedHTMLCallbacks(): void { _getState().serverInsertedHTMLCallbacks = []; }, -}; +} satisfies Parameters[0]; _registerStateAccessors(_accessors); -(globalThis as unknown as Record)[_GLOBAL_ACCESSORS_KEY] = _accessors; +(globalThis as unknown as Record)[GLOBAL_ACCESSORS_KEY] = _accessors; diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 618845f23..f73264ef7 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -142,7 +142,8 @@ interface _StateAccessors { clearInsertedHTMLCallbacks: () => void; } -const _GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); +export const GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); +const _GLOBAL_ACCESSORS_KEY = GLOBAL_ACCESSORS_KEY; type _GlobalWithAccessors = typeof globalThis & { [_GLOBAL_ACCESSORS_KEY]?: _StateAccessors }; function _getGlobalAccessors(): _StateAccessors | undefined { diff --git a/tests/nextjs-compat/use-client-page-pathname.test.ts b/tests/nextjs-compat/use-client-page-pathname.test.ts index 233d00d64..997032ec8 100644 --- a/tests/nextjs-compat/use-client-page-pathname.test.ts +++ b/tests/nextjs-compat/use-client-page-pathname.test.ts @@ -33,7 +33,8 @@ beforeAll(async () => { })); // Warm up so first test doesn't pay cold-start cost. - await fetch(`${_baseUrl}${ROUTE}`); + const warmup = await fetch(`${_baseUrl}${ROUTE}`); + expect(warmup.ok).toBe(true); }, 60_000); afterAll(async () => {