diff --git a/packages/vinext/src/shims/navigation-state.ts b/packages/vinext/src/shims/navigation-state.ts index 41ad99c1..1931d4e1 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({ +import { GLOBAL_ACCESSORS_KEY } from "./navigation"; + +const _accessors = { getServerContext(): NavigationContext | null { return _getState().serverContext; }, @@ -108,4 +117,7 @@ _registerStateAccessors({ clearInsertedHTMLCallbacks(): void { _getState().serverInsertedHTMLCallbacks = []; }, -}); +} satisfies Parameters[0]; + +_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 2e23b5b8..f73264ef 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -122,31 +122,69 @@ 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; +} + +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 { + 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 b5b597fd..c0a7a025 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 00000000..79f3eccf --- /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 00000000..8d862a37 --- /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 00000000..997032ec --- /dev/null +++ b/tests/nextjs-compat/use-client-page-pathname.test.ts @@ -0,0 +1,130 @@ +/** + * 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. + const warmup = await fetch(`${_baseUrl}${ROUTE}`); + expect(warmup.ok).toBe(true); +}, 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); + }); +});