Skip to content
Open
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
18 changes: 15 additions & 3 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ type RenderPagesIsrHtmlOptions = {
pageProps: Record<string, unknown>;
props?: Record<string, unknown>;
params: Record<string, unknown>;
query: Record<string, unknown>;
renderIsrPassToStringAsync: (element: ReactNode) => Promise<string>;
routePattern: string;
safeJsonStringify: (value: unknown) => string;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion packages/vinext/src/server/pages-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -274,6 +275,7 @@ export function buildPagesNextDataScript(
| "pageProps"
| "props"
| "params"
| "query"
| "routePattern"
| "safeJsonStringify"
| "scriptNonce"
Expand All @@ -285,7 +287,13 @@ export function buildPagesNextDataScript(
const nextDataPayload: Record<string, unknown> = {
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,
};
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/vinext/src/server/pages-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
params: Record<string, unknown>;
isFallback: boolean;
autoExport: boolean | undefined;
gsp: boolean | undefined;
}): Record<string, unknown> {
if (opts.isFallback || opts.autoExport || opts.gsp) return opts.params;
return opts.query;
}
1 change: 1 addition & 0 deletions tests/pages-page-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("pages page data", () => {
},
pageProps: { title: "fresh" },
params: { slug: "post" },
query: { slug: "post" },
renderIsrPassToStringAsync: vi.fn(async () => "<div>fresh-body</div>"),
routePattern: "/posts/[slug]",
safeJsonStringify(value: unknown) {
Expand Down
60 changes: 59 additions & 1 deletion tests/pages-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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" });
});
});
68 changes: 68 additions & 0 deletions tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading