Files
cloudflare__vinext/tests/pages-data-route.test.ts
T
James Anderson 576c55d7db feat(pages-router): implement _next/data JSON endpoint (#1384)
* feat(pages-router): implement _next/data JSON endpoint

Recognize `/_next/data/<buildId>/<page>.json` in dev and prod request
pipelines. The URL is normalized to the page path BEFORE middleware
runs (matching Next.js' `handleNextDataRequest`), and the server emits
`{ pageProps }` as JSON with `Content-Type: application/json` instead
of the rendered HTML.

Closes #1330

* fix(pages-router): address bigbonk findings on _next/data endpoint

- Delegate JSON envelope construction from the generated entry to the
  typed `buildNextDataJsonResponse` helper so the envelope shape lives
  in one place (entries stay thin per AGENTS.md).
- Return a JSON 404 from `resolvePagesPageData` when `getStaticPaths`
  with `fallback: false` rejects the path for a data request. Without
  this, client-side navigations to unlisted slugs got an HTML 404 body
  which fails to parse as JSON in the Next.js client router.
- Apply the same JSON-404 gating in the dev server for
  `getStaticPaths fallback:false`, `getServerSideProps.notFound`, and
  `getStaticProps.notFound` paths.
- Read the dev buildId from the plugin's resolved `nextConfig.buildId`
  first so a user-supplied `generateBuildId` is honored, matching the
  value embedded into the prod server entry.
- Add coverage for `getStaticProps` data requests and the
  `fallback:false` JSON-404 path.

* chore: drop unused NextDataMatch export (knip)

* fix(pages-router): JSON 404 for dev stale buildId; HTML 404 for gSSP/gSP notFound on HTML nav

Follow-ups from bigbonk round 2:

- Dev request pipeline now returns a JSON 404 when `_next/data/<wrong>` is
  hit, mirroring the prod-server path. Previously the dev fall-through
  served Vite's HTML 404, breaking client routers that try to parse the
  body as JSON.
- `resolvePagesPageData` now gates the gSSP/gSP `notFound: true` 404 body
  on `isDataReq` so HTML navigations get the HTML 404 page and data
  requests get the JSON envelope. Previously both shapes always returned
  the JSON body, which surfaced text-content-type mismatches on HTML
  navigations through gSSP/gSP `notFound`.
2026-05-21 15:55:33 +01:00

103 lines
3.9 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
isNextDataPathname,
parseNextDataPathname,
buildNextDataJsonResponse,
buildNextDataNotFoundResponse,
} 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({});
});
});
});