From 715fd4424bf22bc296a5d787c0307b41b45bcb38 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:50 +1100 Subject: [PATCH 1/7] fix: stabilize instrumentation state and hydration E2E tests Move Cloudflare instrumentation example state from module-level variables to globalThis so startup and request paths share one owner across different module instances. Add waitForHydration() helper to Pages Router E2E tests to eliminate flaky button-click assertions that raced against React hydration. --- .../instrumentation-state.ts | 49 +++++++++++-------- tests/e2e/pages-router/hydration.spec.ts | 8 ++- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/examples/app-router-cloudflare/instrumentation-state.ts b/examples/app-router-cloudflare/instrumentation-state.ts index c316fc82..a3111f8d 100644 --- a/examples/app-router-cloudflare/instrumentation-state.ts +++ b/examples/app-router-cloudflare/instrumentation-state.ts @@ -1,17 +1,9 @@ /** * Shared state for instrumentation.ts testing in app-router-cloudflare. * - * ## Why plain module-level variables work here - * - * register() is now emitted as a top-level `await` inside the generated RSC - * entry module (see `generateRscEntry` in `entries/app-rsc-entry.ts`). This means it - * runs inside the Cloudflare Worker process — the same process and module graph - * as the API routes. Plain module-level variables are therefore visible to both - * the instrumentation code and the API route that reads them. - * - * This is different from the old approach (writing to a temp file on disk) which - * was needed when register() ran in the host Node.js process and API routes ran - * in the miniflare Worker subprocess (two separate processes, no shared memory). + * Cloudflare dev and worker paths can evaluate instrumentation and route modules + * through different module instances. Store state on globalThis so the startup + * register() path and the request-handling path observe the same values. */ export type CapturedRequestError = { @@ -23,29 +15,44 @@ export type CapturedRequestError = { routeType: string; } -/** Set to true when instrumentation.ts register() is called. */ -let registerCalled = false; +type InstrumentationState = { + capturedErrors: CapturedRequestError[]; + registerCalled: boolean; +}; + +function getInstrumentationState(): InstrumentationState { + const scopedGlobal = globalThis as typeof globalThis & { + __VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__?: InstrumentationState; + }; -/** List of errors captured by onRequestError(). */ -const capturedErrors: CapturedRequestError[] = []; + if (!scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__) { + scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__ = { + capturedErrors: [], + registerCalled: false, + }; + } + + return scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__; +} export function isRegisterCalled(): boolean { - return registerCalled; + return getInstrumentationState().registerCalled; } export function getCapturedErrors(): CapturedRequestError[] { - return [...capturedErrors]; + return [...getInstrumentationState().capturedErrors]; } export function markRegisterCalled(): void { - registerCalled = true; + getInstrumentationState().registerCalled = true; } export function recordRequestError(entry: CapturedRequestError): void { - capturedErrors.push(entry); + getInstrumentationState().capturedErrors.push(entry); } export function resetInstrumentationState(): void { - registerCalled = false; - capturedErrors.length = 0; + const state = getInstrumentationState(); + state.registerCalled = false; + state.capturedErrors.length = 0; } diff --git a/tests/e2e/pages-router/hydration.spec.ts b/tests/e2e/pages-router/hydration.spec.ts index 51f3d95d..720bec4b 100644 --- a/tests/e2e/pages-router/hydration.spec.ts +++ b/tests/e2e/pages-router/hydration.spec.ts @@ -2,6 +2,10 @@ import { test, expect } from "../fixtures"; const BASE = "http://localhost:4173"; +async function waitForHydration(page: import("@playwright/test").Page) { + await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__)); +} + test.describe("Hydration", () => { // The consoleErrors fixture automatically fails tests if any console errors occur. // This catches React hydration mismatches, runtime errors, etc. @@ -12,7 +16,8 @@ test.describe("Hydration", () => { // SSR should render the initial count await expect(page.locator('[data-testid="count"]')).toContainText("Count:"); - // Wait for hydration — button should become interactive + await waitForHydration(page); + await page.click('[data-testid="increment"]'); await expect(page.locator('[data-testid="count"]')).toHaveText("Count: 1"); @@ -57,6 +62,7 @@ test.describe("Hydration", () => { test("state is preserved within client navigation", async ({ page, consoleErrors }) => { // Start at counter page, increment, then navigate away and back await page.goto(`${BASE}/counter`); + await waitForHydration(page); await page.click('[data-testid="increment"]'); await page.click('[data-testid="increment"]'); await expect(page.locator('[data-testid="count"]')).toHaveText("Count: 2"); From ce200513747627e0a8cab9767c6f8898277f9088 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:57 +1100 Subject: [PATCH 2/7] fix: honor the `as` parameter in router.push/replace The `as` parameter in push() and replace() was silently ignored (prefixed as _as) in both the useRouter() hook and the Router singleton. This broke masked URLs and legacy dynamic-route flows. Extract resolveNavigationTarget() helper that uses `as` when provided, falling back to resolveUrl(url) otherwise. Apply to all four call sites: useRouter().push, useRouter().replace, Router.push, Router.replace. --- packages/vinext/src/shims/router.ts | 32 ++++++------- tests/e2e/pages-router/navigation.spec.ts | 47 +++++++++++++++++++ tests/fixtures/pages-basic/pages/nav-test.tsx | 30 +++++++++++- .../fixtures/pages-basic/pages/posts/[id].tsx | 1 + 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 2f736756..ddb0a81f 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -118,6 +118,14 @@ function resolveUrl(url: string | UrlObject): string { return result; } +function resolveNavigationTarget( + url: string | UrlObject, + as: string | undefined, + locale: string | undefined, +): string { + return applyNavigationLocale(as ?? resolveUrl(url), locale); +} + /** * Apply locale prefix to a URL for client-side navigation. * Same logic as Link's applyLocaleToHref but reads from window globals. @@ -464,12 +472,8 @@ export function useRouter(): NextRouter { }, []); const push = useCallback( - async ( - url: string | UrlObject, - _as?: string, - options?: TransitionOptions, - ): Promise => { - let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + async (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise => { + let resolved = resolveNavigationTarget(url, as, options?.locale); // External URLs — delegate to browser (unless same-origin) if (isExternalUrl(resolved)) { @@ -519,12 +523,8 @@ export function useRouter(): NextRouter { ); const replace = useCallback( - async ( - url: string | UrlObject, - _as?: string, - options?: TransitionOptions, - ): Promise => { - let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + async (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise => { + let resolved = resolveNavigationTarget(url, as, options?.locale); // External URLs — delegate to browser (unless same-origin) if (isExternalUrl(resolved)) { @@ -666,8 +666,8 @@ export function wrapWithRouterContext(element: ReactElement): ReactElement { // Also export a default Router singleton for `import Router from 'next/router'` const Router = { - push: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => { - let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + push: async (url: string | UrlObject, as?: string, options?: TransitionOptions) => { + let resolved = resolveNavigationTarget(url, as, options?.locale); // External URLs (unless same-origin) if (isExternalUrl(resolved)) { @@ -710,8 +710,8 @@ const Router = { window.dispatchEvent(new CustomEvent("vinext:navigate")); return true; }, - replace: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => { - let resolved = applyNavigationLocale(resolveUrl(url), options?.locale); + replace: async (url: string | UrlObject, as?: string, options?: TransitionOptions) => { + let resolved = resolveNavigationTarget(url, as, options?.locale); // External URLs (unless same-origin) if (isExternalUrl(resolved)) { diff --git a/tests/e2e/pages-router/navigation.spec.ts b/tests/e2e/pages-router/navigation.spec.ts index 107bc341..02f255e3 100644 --- a/tests/e2e/pages-router/navigation.spec.ts +++ b/tests/e2e/pages-router/navigation.spec.ts @@ -2,10 +2,15 @@ import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4173"; +async function waitForHydration(page: import("@playwright/test").Page) { + await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__)); +} + test.describe("Client-side navigation", () => { test("Link click navigates without full page reload", async ({ page }) => { await page.goto(`${BASE}/`); await expect(page.locator("h1")).toHaveText("Hello, vinext!"); + await waitForHydration(page); // Store a marker on the window to detect if page fully reloaded await page.evaluate(() => { @@ -29,6 +34,7 @@ test.describe("Client-side navigation", () => { test("Link navigates back to home from about", async ({ page }) => { await page.goto(`${BASE}/about`); await expect(page.locator("h1")).toHaveText("About"); + await waitForHydration(page); await page.evaluate(() => { (window as any).__NAV_MARKER__ = true; @@ -45,6 +51,7 @@ test.describe("Client-side navigation", () => { test("router.push navigates to a new page", async ({ page }) => { await page.goto(`${BASE}/nav-test`); await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); await page.evaluate(() => { (window as any).__NAV_MARKER__ = true; @@ -58,6 +65,22 @@ test.describe("Client-side navigation", () => { expect(page.url()).toBe(`${BASE}/about`); }); + test("router.push(url, as) uses the masked URL while resolving the real route", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-test`); + await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); + + await page.click('[data-testid="push-post-as-hook"]'); + await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 42"); + await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 42"); + await expect(page.locator('[data-testid="as-path"]')).toHaveText( + "As Path: /posts/42?from=hook", + ); + expect(page.url()).toBe(`${BASE}/posts/42?from=hook`); + }); + test("router.replace navigates without adding history entry", async ({ page }) => { // Start at home, then go to nav-test, then replace to SSR await page.goto(`${BASE}/`); @@ -66,6 +89,7 @@ test.describe("Client-side navigation", () => { // Navigate to nav-test via direct navigation await page.goto(`${BASE}/nav-test`); await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); await page.click('[data-testid="replace-ssr"]'); await expect(page.locator("h1")).toHaveText("Server-Side Rendered"); @@ -76,9 +100,30 @@ test.describe("Client-side navigation", () => { await expect(page.locator("h1")).not.toHaveText("Navigation Test"); }); + test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => { + await page.goto(`${BASE}/`); + await expect(page.locator("h1")).toHaveText("Hello, vinext!"); + + await page.goto(`${BASE}/nav-test`); + await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); + + await page.click('[data-testid="replace-post-as-singleton"]'); + await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 84"); + await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 84"); + await expect(page.locator('[data-testid="as-path"]')).toHaveText( + "As Path: /posts/84?from=singleton", + ); + expect(page.url()).toBe(`${BASE}/posts/84?from=singleton`); + + await page.goBack(); + await expect(page.locator("h1")).toHaveText("Hello, vinext!"); + }); + test("browser back/forward buttons work after client navigation", async ({ page }) => { await page.goto(`${BASE}/`); await expect(page.locator("h1")).toHaveText("Hello, vinext!"); + await waitForHydration(page); // Navigate: Home -> About via link await page.click('a[href="/about"]'); @@ -98,6 +143,7 @@ test.describe("Client-side navigation", () => { test("multiple sequential navigations work", async ({ page }) => { await page.goto(`${BASE}/nav-test`); await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); await page.evaluate(() => { (window as any).__NAV_MARKER__ = true; @@ -123,6 +169,7 @@ test.describe("Client-side navigation", () => { test("navigating to SSR page fetches fresh server data", async ({ page }) => { await page.goto(`${BASE}/nav-test`); await expect(page.locator("h1")).toHaveText("Navigation Test"); + await waitForHydration(page); await page.click('[data-testid="link-ssr"]'); await expect(page.locator("h1")).toHaveText("Server-Side Rendered"); diff --git a/tests/fixtures/pages-basic/pages/nav-test.tsx b/tests/fixtures/pages-basic/pages/nav-test.tsx index bd208b38..181239de 100644 --- a/tests/fixtures/pages-basic/pages/nav-test.tsx +++ b/tests/fixtures/pages-basic/pages/nav-test.tsx @@ -1,4 +1,4 @@ -import { useRouter } from "next/router"; +import Router, { useRouter } from "next/router"; import Link from "next/link"; export default function NavTestPage() { @@ -16,6 +16,34 @@ export default function NavTestPage() { + + Link to Home diff --git a/tests/fixtures/pages-basic/pages/posts/[id].tsx b/tests/fixtures/pages-basic/pages/posts/[id].tsx index c8db9f6a..4f6ec3c0 100644 --- a/tests/fixtures/pages-basic/pages/posts/[id].tsx +++ b/tests/fixtures/pages-basic/pages/posts/[id].tsx @@ -11,6 +11,7 @@ export default function Post({ id }: PostProps) {

Post: {id}

Pathname: {router.pathname}

+

As Path: {router.asPath}

Query ID: {router.query.id}

); From 3da95b448a389a029d4cc9b3b0262d2246dd5d27 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:40:31 +1100 Subject: [PATCH 3/7] fix: address PR review feedback - Add doc comment to resolveNavigationTarget explaining the url/as simplification vs Next.js's two-value approach - Extract waitForHydration to shared tests/e2e/helpers.ts - Add pathname assertion to verify router.pathname stays as route pattern (/posts/[id]) after as-based navigation --- packages/vinext/src/shims/router.ts | 7 +++++++ tests/e2e/helpers.ts | 5 +++++ tests/e2e/pages-router/hydration.spec.ts | 5 +---- tests/e2e/pages-router/navigation.spec.ts | 6 ++---- 4 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/helpers.ts diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index ddb0a81f..01a8a836 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -118,6 +118,13 @@ function resolveUrl(url: string | UrlObject): string { return result; } +/** + * When `as` is provided, use it as the navigation target. This is a + * simplification: Next.js keeps `url` and `as` as separate values (url for + * data fetching, as for the browser URL). We collapse them because vinext's + * navigateClient() fetches HTML from the target URL, so `as` must be a + * server-resolvable path. Purely decorative `as` values are not supported. + */ function resolveNavigationTarget( url: string | UrlObject, as: string | undefined, diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 00000000..ee0b896f --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,5 @@ +import type { Page } from "@playwright/test"; + +export async function waitForHydration(page: Page) { + await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__)); +} diff --git a/tests/e2e/pages-router/hydration.spec.ts b/tests/e2e/pages-router/hydration.spec.ts index 720bec4b..f1e08394 100644 --- a/tests/e2e/pages-router/hydration.spec.ts +++ b/tests/e2e/pages-router/hydration.spec.ts @@ -1,11 +1,8 @@ import { test, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; const BASE = "http://localhost:4173"; -async function waitForHydration(page: import("@playwright/test").Page) { - await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__)); -} - test.describe("Hydration", () => { // The consoleErrors fixture automatically fails tests if any console errors occur. // This catches React hydration mismatches, runtime errors, etc. diff --git a/tests/e2e/pages-router/navigation.spec.ts b/tests/e2e/pages-router/navigation.spec.ts index 02f255e3..7bd1cb91 100644 --- a/tests/e2e/pages-router/navigation.spec.ts +++ b/tests/e2e/pages-router/navigation.spec.ts @@ -1,11 +1,8 @@ import { test, expect } from "@playwright/test"; +import { waitForHydration } from "../helpers"; const BASE = "http://localhost:4173"; -async function waitForHydration(page: import("@playwright/test").Page) { - await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__)); -} - test.describe("Client-side navigation", () => { test("Link click navigates without full page reload", async ({ page }) => { await page.goto(`${BASE}/`); @@ -78,6 +75,7 @@ test.describe("Client-side navigation", () => { await expect(page.locator('[data-testid="as-path"]')).toHaveText( "As Path: /posts/42?from=hook", ); + await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]"); expect(page.url()).toBe(`${BASE}/posts/42?from=hook`); }); From fad6f976244245db7a9033f5c8952afdc2e2fa1a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:43:22 +1100 Subject: [PATCH 4/7] fix: remove pathname assertion that tests unimplemented behavior router.pathname currently returns the resolved path after client-side navigation, not the route pattern. The correct behavior (returning /posts/[id] instead of /posts/42) requires the two-value url/as approach tracked in #462. --- tests/e2e/pages-router/navigation.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/pages-router/navigation.spec.ts b/tests/e2e/pages-router/navigation.spec.ts index 7bd1cb91..d04802fd 100644 --- a/tests/e2e/pages-router/navigation.spec.ts +++ b/tests/e2e/pages-router/navigation.spec.ts @@ -75,7 +75,8 @@ test.describe("Client-side navigation", () => { await expect(page.locator('[data-testid="as-path"]')).toHaveText( "As Path: /posts/42?from=hook", ); - await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]"); + // TODO(#462): after implementing two-value url/as navigation, assert that + // router.pathname stays as the route pattern (/posts/[id]) not the resolved path. expect(page.url()).toBe(`${BASE}/posts/42?from=hook`); }); From 090b76cbc2f7fc92af822fbfcd6e7e7f8eb06d8a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:48:26 +1100 Subject: [PATCH 5/7] fix: return route pattern from router.pathname, not resolved path In Next.js, router.pathname returns the route pattern (e.g., "/posts/[id]"), not the resolved path ("/posts/42"). vinext was deriving pathname from window.location.pathname which gives the resolved path. Fix: use __NEXT_DATA__.page (which holds the route pattern) as pathname, falling back to location.pathname when __NEXT_DATA__ isn't available. asPath continues to use the resolved browser path. Closes #462 --- packages/vinext/src/shims/router.ts | 9 +++++++-- tests/e2e/pages-router/dynamic-routes.spec.ts | 4 ++-- tests/e2e/pages-router/navigation.spec.ts | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 01a8a836..5e562888 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -274,7 +274,11 @@ function getPathnameAndQuery(): { } return { pathname: "/", query: {}, asPath: "/" }; } - const pathname = stripBasePath(window.location.pathname, __basePath); + const resolvedPath = stripBasePath(window.location.pathname, __basePath); + // In Next.js, router.pathname is the route pattern (e.g., "/posts/[id]"), + // not the resolved path ("/posts/42"). __NEXT_DATA__.page holds the route + // pattern and is updated by navigateClient() on every client-side navigation. + const pathname = window.__NEXT_DATA__?.page ?? resolvedPath; const routeQuery: Record = {}; // Include dynamic route params from __NEXT_DATA__ (e.g., { id: "42" } from /posts/[id]). // Only include keys that are part of the route pattern (not stale query params). @@ -297,7 +301,8 @@ function getPathnameAndQuery(): { addQueryParam(searchQuery, key, value); } const query = { ...searchQuery, ...routeQuery }; - const asPath = pathname + window.location.search + window.location.hash; + // asPath uses the resolved browser path, not the route pattern + const asPath = resolvedPath + window.location.search + window.location.hash; return { pathname, query, asPath }; } diff --git a/tests/e2e/pages-router/dynamic-routes.spec.ts b/tests/e2e/pages-router/dynamic-routes.spec.ts index 35c3023c..a1d1062d 100644 --- a/tests/e2e/pages-router/dynamic-routes.spec.ts +++ b/tests/e2e/pages-router/dynamic-routes.spec.ts @@ -6,8 +6,8 @@ test.describe("Dynamic routes with getServerSideProps", () => { test("renders post page with dynamic id from GSSP", async ({ page }) => { await page.goto(`${BASE}/posts/123`); await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 123"); - // vinext returns the resolved pathname (not the route pattern) - await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/123"); + // router.pathname returns the route pattern, not the resolved path + await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]"); await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 123"); }); diff --git a/tests/e2e/pages-router/navigation.spec.ts b/tests/e2e/pages-router/navigation.spec.ts index d04802fd..7bd1cb91 100644 --- a/tests/e2e/pages-router/navigation.spec.ts +++ b/tests/e2e/pages-router/navigation.spec.ts @@ -75,8 +75,7 @@ test.describe("Client-side navigation", () => { await expect(page.locator('[data-testid="as-path"]')).toHaveText( "As Path: /posts/42?from=hook", ); - // TODO(#462): after implementing two-value url/as navigation, assert that - // router.pathname stays as the route pattern (/posts/[id]) not the resolved path. + await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]"); expect(page.url()).toBe(`${BASE}/posts/42?from=hook`); }); From 509e3401eddac4fb9cdc9f992e65c91bda1fe860 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:03:21 +1100 Subject: [PATCH 6/7] fix: set SSR pathname to route pattern to prevent hydration mismatch All three SSR entry points (dev-server, pages-server-entry, static-export) were setting pathname to the resolved URL path (e.g., "/posts/42") while the client now derives it from __NEXT_DATA__.page (the route pattern "/posts/[id]"). This caused hydration mismatches when components render router.pathname. Fix: use patternToNextFormat(route.pattern) for the SSR context pathname, matching what __NEXT_DATA__.page already provides. --- packages/vinext/src/build/static-export.ts | 4 ++-- packages/vinext/src/entries/pages-server-entry.ts | 2 +- packages/vinext/src/server/dev-server.ts | 2 +- tests/pages-router.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts index 1163898d..79de1cec 100644 --- a/packages/vinext/src/build/static-export.ts +++ b/packages/vinext/src/build/static-export.ts @@ -18,7 +18,7 @@ * - Dynamic routes without generateStaticParams → build error */ import type { ViteDevServer } from "vite"; -import type { Route } from "../routing/pages-router.js"; +import { patternToNextFormat, type Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { ResolvedNextConfig } from "../config/next-config.js"; import { safeJsonStringify } from "../server/html.js"; @@ -277,7 +277,7 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise { expect(html).toMatch(/Post:\s*()?\s*42/); expect(html).toContain("post-title"); // Router should have correct pathname and query during SSR - expect(html).toMatch(/Pathname:\s*()?\s*\/posts\/42/); + expect(html).toMatch(/Pathname:\s*()?\s*\/posts\/\[id\]/); expect(html).toMatch(/Query ID:\s*()?\s*42/); }); From eeb5fdca661320dccc57d5404897757f3a9a8715 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 10:56:23 +0000 Subject: [PATCH 7/7] test: update entry-templates snapshots for pathname fix --- tests/__snapshots__/entry-templates.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 40ce67c8..7b814337 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18128,7 +18128,7 @@ async function _renderPage(request, url, manifest) { try { if (typeof setSSRContext === "function") { setSSRContext({ - pathname: routeUrl.split("?")[0], + pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl, locale: locale,