diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index eda610688..bfe67c1b4 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -61,7 +61,7 @@ import { resolvePagesI18nRequest, } from "./pages-i18n.js"; import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js"; -import { buildPagesReadinessNextData } from "./pages-readiness.js"; +import { buildPagesReadinessNextData, computePagesNextDataQuery } from "./pages-readiness.js"; import { resolvePagesPageMethodResponse } from "./pages-page-method.js"; import { getPagesRouteParams, @@ -1490,7 +1490,13 @@ export function createSSRHandler( { props: freshRenderProps, page: patternToNextFormat(route.pattern), - query: params, + query: computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: freshPagesNextData.autoExport, + gsp: freshPagesNextData.gsp, + }), buildId: process.env.__VINEXT_BUILD_ID, isFallback: false, locale: locale ?? currentDefaultLocale, @@ -1869,7 +1875,13 @@ export function createSSRHandler( { props: renderProps, page: patternToNextFormat(route.pattern), - query: params, + query: computePagesNextDataQuery({ + query, + params, + isFallback: isFallbackRender, + autoExport: serializedPagesNextData.autoExport, + gsp: serializedPagesNextData.gsp, + }), buildId: process.env.__VINEXT_BUILD_ID, isFallback: isFallbackRender, locale: locale ?? currentDefaultLocale, diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index ed11e5e3c..20fe1215b 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -136,6 +136,7 @@ type RenderPagesIsrHtmlOptions = { pageProps: Record; props?: Record; params: Record; + query: Record; renderIsrPassToStringAsync: (element: ReactNode) => Promise; routePattern: string; safeJsonStringify: (value: unknown) => string; @@ -653,6 +654,7 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr pageProps: options.pageProps, props: renderProps, params: options.params, + query: options.query, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, // Serialize the same readiness flags (gssp/gsp/autoExport/…) the initial @@ -947,6 +949,7 @@ export async function resolvePagesPageData( pageProps: freshPageProps, props: freshRenderProps, params: options.params, + query: options.query, renderIsrPassToStringAsync: options.renderIsrPassToStringAsync, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 115940836..e3d1171ef 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -23,6 +23,7 @@ import { type RenderPageEnhancers, runDocumentRenderPage, } from "./pages-document-initial-props.js"; +import { computePagesNextDataQuery } from "./pages-readiness.js"; import { fnv1a52 } from "../utils/hash.js"; import { readStreamAsText } from "../utils/text-stream.js"; import { callDocumentGetInitialProps } from "./document-initial-head.js"; @@ -274,6 +275,7 @@ export function buildPagesNextDataScript( | "pageProps" | "props" | "params" + | "query" | "routePattern" | "safeJsonStringify" | "scriptNonce" @@ -285,7 +287,13 @@ export function buildPagesNextDataScript( const nextDataPayload: Record = { props: options.props ?? { pageProps: options.pageProps }, page: options.routePattern, - query: options.params, + query: computePagesNextDataQuery({ + query: options.query ?? options.params, + params: options.params, + isFallback: options.isFallback === true, + autoExport: options.nextData?.autoExport, + gsp: options.nextData?.gsp, + }), buildId: options.buildId, isFallback: options.isFallback === true, }; @@ -503,6 +511,7 @@ export async function renderPagesPageResponse( pageProps: options.pageProps, props: renderProps, params: options.params, + query: options.query, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, scriptNonce: options.scriptNonce, diff --git a/packages/vinext/src/server/pages-readiness.ts b/packages/vinext/src/server/pages-readiness.ts index 01ac446c2..0606c5d24 100644 --- a/packages/vinext/src/server/pages-readiness.ts +++ b/packages/vinext/src/server/pages-readiness.ts @@ -53,3 +53,41 @@ export function buildPagesReadinessNextData(options: { __vinext: { hasRewrites: options.hasRewrites }, }; } + +/** + * Compute the `__NEXT_DATA__.query` value for a Pages Router SSR render so the + * inlined value is identical to what the client router writes after a soft + * navigation (see `shims/router.ts`'s `mergeRouteParamsIntoQuery` call). Shared + * by the prod (`buildPagesNextDataScript`) and dev SSR render paths so they + * cannot drift. + * + * - getServerSideProps / page or _app `getInitialProps` → full merged query + * (URL querystring + route params). This is the #1970 fix: a per-request + * server render's `__NEXT_DATA__.query` must carry the querystring, matching + * Next.js and the client router after a soft nav. + * - getStaticProps → route params only; the querystring is dropped (a static + * page's output can't depend on the request query — Next.js parity). + * - autoExport / getStaticPaths fallback shell → route params only. + * + * Every non-gSSP/gIP case keeps the route params (rather than resetting to `{}` + * for autoExport/fallback as Next.js does) because vinext's client recovers a + * dynamic route's params from `__NEXT_DATA__.query` whenever the visible URL + * path doesn't match the route pattern — e.g. a rewrite from + * `/rewrite-navigation/0` to `/rewrite-navigation/[id]/destination`, where the + * params live nowhere else on the page (`getRouteQueryFromNextData` in + * `shims/router.ts`). Emptying the query there would drop `useRouter().query`'s + * route params on hydration. + * + * `query` is the already-merged query (querystring + route params); `params` is + * the route-match params only. + */ +export function computePagesNextDataQuery(opts: { + query: Record; + params: Record; + isFallback: boolean; + autoExport: boolean | undefined; + gsp: boolean | undefined; +}): Record { + if (opts.isFallback || opts.autoExport || opts.gsp) return opts.params; + return opts.query; +} diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index b2278a19e..633db9c3c 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -97,6 +97,7 @@ describe("pages page data", () => { }, pageProps: { title: "fresh" }, params: { slug: "post" }, + query: { slug: "post" }, renderIsrPassToStringAsync: vi.fn(async () => "
fresh-body
"), routePattern: "/posts/[slug]", safeJsonStringify(value: unknown) { diff --git a/tests/pages-readiness.test.ts b/tests/pages-readiness.test.ts index ab2b00f46..551df86f8 100644 --- a/tests/pages-readiness.test.ts +++ b/tests/pages-readiness.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPagesReadinessNextData } from "../packages/vinext/src/server/pages-readiness.js"; +import { + buildPagesReadinessNextData, + computePagesNextDataQuery, +} from "../packages/vinext/src/server/pages-readiness.js"; import App from "../packages/vinext/src/shims/app.js"; import { getPagesNavigationIsReadyFromSerializedState } from "../packages/vinext/src/shims/router.js"; @@ -95,3 +98,58 @@ describe("buildPagesReadinessNextData", () => { }); }); }); + +describe("computePagesNextDataQuery", () => { + const query = { id: "42", q: "foo" }; + const params = { id: "42" }; + + it("returns the full merged query for getServerSideProps pages (#1970)", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: false, + gsp: undefined, + }), + ).toEqual({ id: "42", q: "foo" }); + }); + + it("drops the querystring for getStaticProps pages (route params only)", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: false, + gsp: true, + }), + ).toEqual({ id: "42" }); + }); + + it("keeps route params for autoExport pages (drops the querystring)", () => { + // Route params are kept (not reset to {}) so vinext's client can recover a + // rewritten dynamic route's params from __NEXT_DATA__.query on hydration. + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: true, + gsp: undefined, + }), + ).toEqual({ id: "42" }); + }); + + it("keeps route params for getStaticPaths fallback shell renders", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: true, + autoExport: false, + gsp: undefined, + }), + ).toEqual({ id: "42" }); + }); +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 7bdf6d8f5..f3a045d96 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -8481,6 +8481,74 @@ describe("router __NEXT_DATA__ correctness (Pages Router)", () => { expect(nextData.props.pageProps.gsspCallId).toBeGreaterThan(0); }); + // Regression tests for GitHub issue #1970 — SSR-serialized __NEXT_DATA__.query + // must match Next.js (and vinext's own client router after a soft nav), which + // serialize the full merged query (querystring + route params) for gSSP/gIP + // pages, route params only for getStaticProps, and {} for autoExport/fallback. + it("gSSP page includes querystring + route params in __NEXT_DATA__.query (#1970)", async () => { + const res = await fetch(`${routerBaseUrl}/posts/hello-world?q=foo&utm=bar`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ id: "hello-world", q: "foo", utm: "bar" }); + }); + + it("gSSP route params win over same-name querystring in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/posts/42?id=evil`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ id: "42" }); + }); + + it("gSSP non-dynamic page includes array querystring values in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/shallow-test?a=1&a=2`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ a: ["1", "2"] }); + }); + + it("getStaticProps page drops the querystring from __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/blog/hello-world?x=1`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ slug: "hello-world" }); + }); + + it("autoExport dynamic page keeps route params (drops querystring) in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/nav-compat/foobar?a=pages`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.page).toBe("/nav-compat/[slug]"); + // Route params are retained (not reset to {}) so the client can recover them + // on hydration; the non-route querystring is dropped for a static render. + expect(nextData.query).toEqual({ slug: "foobar" }); + }); + + it("getInitialProps page includes the full merged query in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/nav-compat-gip/foobar?q=pages`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ slug: "foobar", q: "pages" }); + }); + + it("rewritten autoExport route keeps its route params in __NEXT_DATA__.query", async () => { + // Regression guard for the rewrite case the navigation e2e exercises: + // /rewrite-navigation/0 rewrites to /rewrite-navigation/[id]/destination, so + // the visible URL path doesn't contain the `id` param. The client recovers + // it from __NEXT_DATA__.query, so the serialized query must keep `id`. + const res = await fetch(`${routerBaseUrl}/rewrite-navigation/0`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.page).toBe("/rewrite-navigation/[id]/destination"); + expect(nextData.query).toEqual({ id: "0" }); + }); + // Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites // Regression test for GitHub issue #1196 — catch-all + basePath + rewrites + middleware.