Files
cloudflare__vinext/tests/pages-data-route.test.ts
T
James Anderson f5355964aa fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses (#1863)
* fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses

Pages Router `/_next/data/<buildId>/<page>.json` responses were missing the
`x-nextjs-deployment-id` response header that Next.js sets on these routes
when a deployment ID is configured. The client router reads this header to
detect when a new deployment has been rolled out and triggers a hard
navigation for deployment-skew protection.

Root cause: the `isDataReq` short-circuit path in `pages-page-handler.ts`
that builds the JSON envelope (`buildNextDataJsonResponse`) never injected
the header. Next.js's `pages-handler.ts` sets `NEXT_NAV_DEPLOYMENT_ID_HEADER`
on every `isNextDataRequest` response (success, redirect, and notFound cases).

Fix: read `process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID`
(the runtime-available deployment ID, inlined at build time by Vite's define
transform) and set `x-nextjs-deployment-id` on the `_next/data` response
headers when a deployment ID is configured.

Maps to test: `pages ssg data deployment skew - hard navigate when a new
deployment occurs > header with deployment id > header is set on data routes`.

Fixes #1829

* fix(pages-router): complete x-nextjs-deployment-id parity for _next/data (bonk #1829)

Address all three ask-bonk findings on PR #1863:

1. Incomplete parity — add x-nextjs-deployment-id to redirect and notFound _next/data
   exits in pages-page-data.ts (buildPagesRedirectResponse, buildPagesDataNotFoundResponse).
   Thread deploymentId through ResolvePagesPageDataOptions and pass it from
   pages-page-handler.ts, matching the success-path pattern already in that file.

2. Dev/prod parity — patch dev-server.ts to set x-nextjs-deployment-id on all three
   _next/data exits: the writeGsspRedirect isDataReq path, the getStaticPaths/
   getServerSideProps/getStaticProps notFound isDataReq paths, and the success
   JSON envelope short-circuit (isDataReq block). Mirrors the prod handler.

3. Add regression tests asserting x-nextjs-deployment-id is present on _next/data
   success / redirect / notFound responses in both pages-page-data.test.ts (unit,
   via deploymentId option) and pages-page-handler.test.ts (integration, via
   process.env.__VINEXT_DEPLOYMENT_ID), and absent when no env var is set.

* fix(pages-router): add x-nextjs-deployment-id to wrong-buildId/route-miss _next/data 404s (#1829)

Fixes the deployment-skew gap identified in review: `buildNextDataNotFoundResponse` now
includes the `x-nextjs-deployment-id` header so all three wrong-buildId/route-miss exits
(normalizePagesDataRequest, prod-server Node path, pages-page-handler route-miss) surface
the deployment ID to the client router. Adds unit tests for the new header on all relevant
exits.

* fix(pages-router): set deployment-id header on remaining two dev-mode _next/data 404 exits (#1829)

Patches the stale-buildId 404 in the vite-dev middleware (index.ts) and
the route-miss data 404 in createSSRHandler (dev-server.ts) to set
x-nextjs-deployment-id, matching prod parity via buildNextDataNotFoundResponse().

* test(pages-router): cover dev-mode _next/data deployment-id header exits

Drive the two dev-only exits flagged in review through the real fixture
dev server: the wrong-buildId data 404 in the plugin middleware
(index.ts) and createSSRHandler's route-miss data 404 (dev-server.ts),
plus the dev success path and the no-deployment-id case.

* fix(pages-router): skip deployment-id header on /_error and /500 data success responses

Next.js pages-handler.ts guards the success-path x-nextjs-deployment-id
injection with `!isErrorPage && !is500Page`. Mirror that exclusion in
both the prod success short-circuit (pages-page-handler.ts) and the dev
one (dev-server.ts), and lock it in with a prod unit test (/_error and
/500) plus a dev integration test (/500).
2026-06-11 00:39:16 +00:00

199 lines
7.6 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
isNextDataPathname,
parseNextDataPathname,
buildNextDataJsonResponse,
buildNextDataNotFoundResponse,
normalizePagesDataRequest,
} from "../packages/vinext/src/server/pages-data-route.js";
// Helper mirroring vinext/html safeJsonStringify behavior for tests.
const safeJsonStringify = (value: unknown) => JSON.stringify(value);
describe("pages-data-route", () => {
describe("isNextDataPathname", () => {
it("returns true for valid _next/data paths regardless of buildId", () => {
expect(isNextDataPathname("/_next/data/abc/about.json")).toBe(true);
expect(isNextDataPathname("/_next/data/abc/en/about.json")).toBe(true);
expect(isNextDataPathname("/_next/data/wrong/about.json")).toBe(true);
});
it("returns false for non-_next/data paths", () => {
expect(isNextDataPathname("/about")).toBe(false);
expect(isNextDataPathname("/_next/static/chunks/foo.js")).toBe(false);
expect(isNextDataPathname("/_next/data/abc/about")).toBe(false); // no .json
expect(isNextDataPathname("/data/abc/about.json")).toBe(false);
});
});
describe("parseNextDataPathname", () => {
const BUILD_ID = "abc123";
it("extracts the page pathname from a flat route", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/about.json`, BUILD_ID)).toEqual({
pagePathname: "/about",
});
});
it("preserves locale prefix (handled by downstream i18n resolver)", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/en/about.json`, BUILD_ID)).toEqual({
pagePathname: "/en/about",
});
});
it("preserves nested segments", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/blog/post-1.json`, BUILD_ID)).toEqual({
pagePathname: "/blog/post-1",
});
});
it("denormalizes `index` to `/`", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/index.json`, BUILD_ID)).toEqual({
pagePathname: "/",
});
});
it("denormalizes trailing `/index` to a directory", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/en/index.json`, BUILD_ID)).toEqual({
pagePathname: "/en",
});
});
it("returns null for a mismatched buildId", () => {
expect(parseNextDataPathname(`/_next/data/other-build/about.json`, BUILD_ID)).toBeNull();
});
it("returns null when the prefix is missing the trailing slash", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}.json`, BUILD_ID)).toBeNull();
});
it("returns null for empty page segment", () => {
expect(parseNextDataPathname(`/_next/data/${BUILD_ID}/.json`, BUILD_ID)).toBeNull();
});
it("returns null for non-_next/data paths", () => {
expect(parseNextDataPathname("/about", BUILD_ID)).toBeNull();
expect(parseNextDataPathname("/_next/static/x.json", BUILD_ID)).toBeNull();
});
it("returns null when buildId is empty", () => {
expect(parseNextDataPathname(`/_next/data/abc/about.json`, "")).toBeNull();
});
});
describe("buildNextDataJsonResponse", () => {
it("returns a 200 JSON envelope with pageProps key", async () => {
const res = buildNextDataJsonResponse({ message: "hi" }, safeJsonStringify);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(await res.json()).toEqual({ pageProps: { message: "hi" } });
});
});
describe("buildNextDataNotFoundResponse", () => {
it("returns a 404 JSON response with empty body", async () => {
const res = buildNextDataNotFoundResponse();
expect(res.status).toBe(404);
expect(res.headers.get("Content-Type")).toBe("application/json");
// Body is `{}` — clients blindly calling `.json()` won't throw before
// checking the status code.
expect(await res.json()).toEqual({});
});
it("sets x-nextjs-deployment-id when __VINEXT_DEPLOYMENT_ID is set", async () => {
const saved = process.env.__VINEXT_DEPLOYMENT_ID;
process.env.__VINEXT_DEPLOYMENT_ID = "deploy-abc123";
try {
const res = buildNextDataNotFoundResponse();
expect(res.headers.get("x-nextjs-deployment-id")).toBe("deploy-abc123");
} finally {
if (saved === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = saved;
}
}
});
it("sets x-nextjs-deployment-id from NEXT_DEPLOYMENT_ID when __VINEXT_DEPLOYMENT_ID is absent", async () => {
const savedVinext = process.env.__VINEXT_DEPLOYMENT_ID;
const savedNext = process.env.NEXT_DEPLOYMENT_ID;
delete process.env.__VINEXT_DEPLOYMENT_ID;
process.env.NEXT_DEPLOYMENT_ID = "next-deploy-xyz";
try {
const res = buildNextDataNotFoundResponse();
expect(res.headers.get("x-nextjs-deployment-id")).toBe("next-deploy-xyz");
} finally {
if (savedVinext === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = savedVinext;
}
if (savedNext === undefined) {
delete process.env.NEXT_DEPLOYMENT_ID;
} else {
process.env.NEXT_DEPLOYMENT_ID = savedNext;
}
}
});
it("omits x-nextjs-deployment-id when no deployment env var is set", async () => {
const savedVinext = process.env.__VINEXT_DEPLOYMENT_ID;
const savedNext = process.env.NEXT_DEPLOYMENT_ID;
delete process.env.__VINEXT_DEPLOYMENT_ID;
delete process.env.NEXT_DEPLOYMENT_ID;
try {
const res = buildNextDataNotFoundResponse();
expect(res.headers.get("x-nextjs-deployment-id")).toBeNull();
} finally {
if (savedVinext !== undefined) process.env.__VINEXT_DEPLOYMENT_ID = savedVinext;
if (savedNext !== undefined) process.env.NEXT_DEPLOYMENT_ID = savedNext;
}
});
});
describe("normalizePagesDataRequest — wrong buildId", () => {
const BUILD_ID = "correct-build-id";
it("returns a notFoundResponse with x-nextjs-deployment-id when buildId is wrong", async () => {
const savedId = process.env.__VINEXT_DEPLOYMENT_ID;
process.env.__VINEXT_DEPLOYMENT_ID = "deploy-skew-guard";
try {
const req = new Request("http://localhost/_next/data/stale-build-id/about.json");
const result = normalizePagesDataRequest(req, BUILD_ID);
expect(result.isDataReq).toBe(false);
expect(result.notFoundResponse).not.toBeNull();
expect(result.notFoundResponse!.status).toBe(404);
expect(result.notFoundResponse!.headers.get("x-nextjs-deployment-id")).toBe(
"deploy-skew-guard",
);
} finally {
if (savedId === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = savedId;
}
}
});
it("returns a notFoundResponse with x-nextjs-deployment-id when buildId is null (no build)", async () => {
const savedId = process.env.__VINEXT_DEPLOYMENT_ID;
process.env.__VINEXT_DEPLOYMENT_ID = "deploy-null-build";
try {
const req = new Request("http://localhost/_next/data/any-build-id/about.json");
const result = normalizePagesDataRequest(req, null);
expect(result.notFoundResponse).not.toBeNull();
expect(result.notFoundResponse!.headers.get("x-nextjs-deployment-id")).toBe(
"deploy-null-build",
);
} finally {
if (savedId === undefined) {
delete process.env.__VINEXT_DEPLOYMENT_ID;
} else {
process.env.__VINEXT_DEPLOYMENT_ID = savedId;
}
}
});
});
});