Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/vinext/src/shims/navigation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,18 @@ export function runWithServerInsertedHTMLState<T>(fn: () => T | Promise<T>): 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;
},
Expand All @@ -108,4 +117,7 @@ _registerStateAccessors({
clearInsertedHTMLCallbacks(): void {
_getState().serverInsertedHTMLCallbacks = [];
},
});
} satisfies Parameters<typeof _registerStateAccessors>[0];

_registerStateAccessors(_accessors);
(globalThis as unknown as Record<PropertyKey, unknown>)[GLOBAL_ACCESSORS_KEY] = _accessors;
58 changes: 48 additions & 10 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit for future readers: after _registerStateAccessors runs on the SSR entry's module instance, these let variables are overwritten to point directly at the ALS-backed functions. The _getGlobalAccessors() fallback in these defaults only fires from the other module instance that never had _registerStateAccessors called. The cost of the extra property lookup on globalThis per hook invocation in the unregistered instance is negligible.

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;
Expand Down
34 changes: 34 additions & 0 deletions tests/e2e/app-router/routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div id="client-page-dynamic-info">
<span id="client-page-dynamic-pathname">{pathname}</span>
<span id="client-page-dynamic-slug">{String(params.slug ?? "")}</span>
<span id="client-page-dynamic-search">{searchParams.toString()}</span>
</div>
);
}
28 changes: 28 additions & 0 deletions tests/fixtures/app-basic/app/use-client-page-pathname/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div id="client-page-info">
<span id="client-page-pathname">{pathname}</span>
<span id="client-page-search-q">{searchParams.get("q") ?? ""}</span>
<span id="client-page-search-string">{searchParams.toString()}</span>
</div>
);
}
130 changes: 130 additions & 0 deletions tests/nextjs-compat/use-client-page-pathname.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<span id="client-page-pathname">${ROUTE}</span>`);
});

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('<span id="client-page-search-q">hello</span>');
expect(html).toContain('<span id="client-page-search-string">q=hello&amp;page=2</span>');
});

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(`<span id="client-page-dynamic-pathname">${dynamicPath}</span>`);
});

it("SSR HTML contains correct slug param", async () => {
const res = await fetch(`${_baseUrl}${dynamicPath}`);
const html = await res.text();

expect(html).toContain('<span id="client-page-dynamic-slug">my-slug</span>');
});

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);
});
});
Loading