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
8 changes: 4 additions & 4 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,8 +749,8 @@ export function createSSRHandler(
if (isDataReq) {
// Data requests get a JSON 404 so the client router can
// hard-navigate instead of trying to parse HTML as JSON.
// Mirror Next.js pages-handler.ts: set x-nextjs-deployment-id on
// `_next/data` notFound exits for deployment-skew protection. Fixes #1829.
// Set x-nextjs-deployment-id on the data 404 for deployment-skew
// protection while keeping the empty-object hard-navigation shape.
const deploymentId =
process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID;
const notFoundHeaders: Record<string, string> = {
Expand Down Expand Up @@ -906,7 +906,7 @@ export function createSSRHandler(
};
if (deploymentId) notFoundHeaders[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId;
res.writeHead(404, notFoundHeaders);
res.end("{}");
res.end('{"notFound":true}');
return;
}
await renderErrorPage(
Expand Down Expand Up @@ -1342,7 +1342,7 @@ export function createSSRHandler(
};
if (deploymentId) notFoundHeaders[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId;
res.writeHead(404, notFoundHeaders);
res.end("{}");
res.end('{"notFound":true}');
return;
}
await renderErrorPage(
Expand Down
40 changes: 23 additions & 17 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,22 @@ type ResolvePagesPageDataResult =

function buildPagesDataNotFoundResponse(deploymentId?: string): Response {
// Matches Next.js: `/_next/data/<buildId>/<page>.json` 404 responses use
// application/json with an empty object body so clients can call
// `res.json()` without throwing before inspecting the status code.
// application/json with a notFound marker so the client router can render
// the configured 404 page without hard-navigating. Missing routes/build IDs
// still use the empty-object deploy-skew response in the outer handlers.
// Mirror Next.js pages-handler.ts: set x-nextjs-deployment-id on all
// `_next/data` notFound exits so the client can detect a new deployment.
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (deploymentId) {
headers[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId;
}
return new Response('{"notFound":true}', {
status: 404,
headers,
});
}

function buildPagesDataFallbackMissResponse(deploymentId?: string): Response {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (deploymentId) {
headers[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId;
Expand Down Expand Up @@ -634,9 +646,15 @@ export async function resolvePagesPageData(

if (fallback === false && !isValidPath) {
// For data requests (`/_next/data/...json`), return a JSON-shaped 404
// so the client router can `res.json()` without blowing up — matches
// Next.js' behavior. HTML navigations still get the configured 404 page.
return buildPagesNotFoundResult(options);
// without the notFound marker so the client router hard-navigates,
// matching Next.js' NoFallbackError path.
if (options.isDataReq) {
return {
kind: "response",
response: buildPagesDataFallbackMissResponse(options.deploymentId),
};
}
return { kind: "notFound" };
}

// Render the fallback shell for unlisted paths under `fallback: true`.
Expand Down Expand Up @@ -741,18 +759,6 @@ export async function resolvePagesPageData(
return buildPagesNotFoundResult(options);
}

// Mirrors Next.js render.tsx's `isSerializableProps(pathname, "getServerSideProps", data.props)`
// check, gated on `!metadata.isRedirect && !metadata.isNotFound` (both
// short-circuit above). Throws a friendly `SerializableError` so the
// caller's existing try/catch surfaces a clear 500 instead of rendering
// an empty page. See
// .nextjs-ref/packages/next/src/server/render.tsx (~line 1200) and
// .nextjs-ref/packages/next/src/lib/is-serializable-props.ts. Tracked in
// vinext#1478.
if (result?.props !== undefined) {
isSerializableProps(options.routePattern, "getServerSideProps", pageProps);
}

gsspRes = res;
}

Expand Down
8 changes: 4 additions & 4 deletions packages/vinext/src/server/pages-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ export function buildPagesReadinessNextData(options: {
?.getInitialProps === "function";
const hasAppGip = typeof options.appComponent?.getInitialProps === "function";
return {
gssp: hasPageGssp,
gssp: hasPageGssp ? true : undefined,
gsp: hasPageGsp ? true : undefined,
gip: hasPageGip,
appGip: hasAppGip,
autoExport: !hasPageGssp && !hasPageGsp && !hasPageGip && !hasAppGip,
gip: hasPageGip ? true : undefined,
appGip: hasAppGip ? true : undefined,
autoExport: !hasPageGssp && !hasPageGsp && !hasPageGip && !hasAppGip ? true : undefined,
__vinext: { hasRewrites: options.hasRewrites },
};
}
44 changes: 42 additions & 2 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,12 @@ function scheduleHardNavigationAndThrow(url: string, message: string): never {

type NavigateClientOptions = {
allowNotFoundResponse?: boolean;
/**
* Route state to preserve while rendering an error-route component. A
* getServerSideProps/getStaticProps notFound transition renders /404, but
* Next.js keeps pathname/route/query/asPath pointed at the requested route.
*/
nextDataRouteState?: Pick<VinextNextData, "page" | "query">;
/**
* The history mode of the originating navigation. Used when a gSSP/gSP data
* response carries a `__N_REDIRECT` marker so the re-entrant navigation to
Expand Down Expand Up @@ -1701,6 +1707,37 @@ async function navigateClientData(
return;
}

if (res.status === 404) {
let isNotFound = false;
try {
const notFoundBody = (await res.clone().json()) as unknown;
isNotFound = isUnknownRecord(notFoundBody) && notFoundBody.notFound === true;
} catch {
// A stale build/data URL can return a non-JSON 404. Keep the existing
// deploy-skew hard-navigation fallback for that response shape.
}

if (isNotFound) {
const notFoundFetchUrl = resolvePagesErrorHtmlFetchUrl("/404", initialTarget.locale);
if (!notFoundFetchUrl) {
scheduleHardNavigationAndThrow(url, "Data navigation failed: no 404 route available");
}
const requestedQuery = mergeRouteParamsIntoQuery(
parseQueryString(initialTarget.search),
initialTarget.params,
);
await navigateClientHtml(url, notFoundFetchUrl, controller, navId, assertStillCurrent, {
...options,
allowNotFoundResponse: true,
nextDataRouteState: {
page: initialTarget.pattern,
query: requestedQuery,
},
});
return;
}
}

if (!res.ok) {
// 404 here is the deploy-skew signal (server buildId rotated) — hard
// reload to land on the new build's HTML. Any other non-OK status is
Expand Down Expand Up @@ -2020,8 +2057,11 @@ async function navigateClientHtml(
window.history.replaceState(window.history.state ?? {}, "", pendingRedirectHistoryUrl);
routerRuntimeState.lastPathnameAndSearch = window.location.pathname + window.location.search;
}
window.__NEXT_DATA__ = nextData;
applyVinextLocaleGlobals(window, nextData);
const committedNextData = options.nextDataRouteState
? { ...nextData, ...options.nextDataRouteState }
: nextData;
window.__NEXT_DATA__ = committedNextData;
applyVinextLocaleGlobals(window, committedNextData);
await renderPagesRouterElement(element, options.scroll);
assertStillCurrent();
}
Expand Down
52 changes: 52 additions & 0 deletions tests/e2e/pages-router-prod/production.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,58 @@
expect(nextData.props.pageProps.message).toBe("Hello from getServerSideProps");
});

test("omits gssp from __NEXT_DATA__ for non-GSSP pages", async ({ page }) => {
// Ported from Next.js: test/e2e/getserversideprops/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/getserversideprops/test/index.test.ts
await page.goto(`${BASE}/about`);
const nextData = await page.evaluate(() => (window as any).__NEXT_DATA__);
expect("gssp" in nextData).toBe(false);
});

for (const href of ["/gssp-not-found?hiding=true", "/gssp-not-found/first?hiding=true"]) {
test(`renders the 404 page on GSSP client navigation to ${href}`, async ({ page }) => {
// Ported from Next.js: test/e2e/getserversideprops/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/getserversideprops/test/index.test.ts
await page.goto(`${BASE}/`);
await page.evaluate((target) => (window as any).next.router.push(target), href);
await expect(page.getByTestId("error-title")).toBeVisible();
expect(page.url()).toContain(href);
});
}

test("preserves requested dynamic route state while rendering GSSP notFound", async ({
page,
}) => {
await page.goto(`${BASE}/`);
await page.evaluate(() =>
(window as any).next.router.push("/gssp-not-found/first?hiding=true"),
);
await expect(page.getByTestId("error-title")).toBeVisible();

const state = await page.evaluate(() => ({
pathname: (window as any).next.router.pathname,
route: (window as any).next.router.route,
query: (window as any).next.router.query,
asPath: (window as any).next.router.asPath,
nextDataPage: (window as any).__NEXT_DATA__.page,
}));
expect(state).toEqual({

Check failure on line 78 in tests/e2e/pages-router-prod/production.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (pages-router-prod)

[pages-router-prod] › tests/e2e/pages-router-prod/production.spec.ts:62:7 › Pages Router Production Build › preserves requested dynamic route state while rendering GSSP notFound

1) [pages-router-prod] › tests/e2e/pages-router-prod/production.spec.ts:62:7 › Pages Router Production Build › preserves requested dynamic route state while rendering GSSP notFound Error: expect(received).toEqual(expected) // deep equality - Expected - 4 + Received + 3 Object { "asPath": "/gssp-not-found/first?hiding=true", - "nextDataPage": "/gssp-not-found/[slug]", - "pathname": "/gssp-not-found/[slug]", + "nextDataPage": "/404", + "pathname": "/404", "query": Object { "hiding": "true", - "slug": "first", }, - "route": "/gssp-not-found/[slug]", + "route": "/404", } 76 | nextDataPage: (window as any).__NEXT_DATA__.page, 77 | })); > 78 | expect(state).toEqual({ | ^ 79 | pathname: "/gssp-not-found/[slug]", 80 | route: "/gssp-not-found/[slug]", 81 | query: { hiding: "true", slug: "first" }, at /home/runner/work/vinext/vinext/tests/e2e/pages-router-prod/production.spec.ts:78:19
pathname: "/gssp-not-found/[slug]",
route: "/gssp-not-found/[slug]",
query: { hiding: "true", slug: "first" },
asPath: "/gssp-not-found/first?hiding=true",
nextDataPage: "/gssp-not-found/[slug]",
});
});

test("renders non-JSON getServerSideProps values in production", async ({ request }) => {
// Ported from Next.js: test/e2e/getserversideprops/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/getserversideprops/test/index.test.ts
const response = await request.get(`${BASE}/gssp-non-json`);
expect(response.status()).toBe(200);
expect(await response.text()).toContain("hello ");
});

test("API route returns JSON", async ({ request }) => {
const response = await request.get(`${BASE}/api/hello`);
expect(response.status()).toBe(200);
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/pages-basic/pages/gssp-non-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";

export default function GsspNonJson({
time,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <p data-testid="gssp-non-json">hello {time.toString()}</p>;
}

export const getServerSideProps: GetServerSideProps<{ time: Date }> = async () => ({
props: { time: new Date("2026-06-21T00:00:00.000Z") },
});
9 changes: 9 additions & 0 deletions tests/fixtures/pages-basic/pages/gssp-not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { GetServerSideProps } from "next";

export default function GsspNotFound() {
return <p>visible page</p>;
}

export const getServerSideProps: GetServerSideProps = async ({ query }) => {
return query.hiding === "true" ? { notFound: true } : { props: {} };
};
9 changes: 9 additions & 0 deletions tests/fixtures/pages-basic/pages/gssp-not-found/[slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { GetServerSideProps } from "next";

export default function DynamicGsspNotFound() {
return <p>visible dynamic page</p>;
}

export const getServerSideProps: GetServerSideProps = async ({ query }) => {
return query.hiding === "true" ? { notFound: true } : { props: {} };
};
6 changes: 6 additions & 0 deletions tests/fixtures/pages-basic/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export default function Home() {
<h1>Hello, vinext!</h1>
<p>This is a Pages Router app running on Vite.</p>
<Link href="/about">Go to About</Link>
<Link id="gssp-not-found" href="/gssp-not-found?hiding=true">
GSSP not found
</Link>
<Link id="gssp-dynamic-not-found" href="/gssp-not-found/first?hiding=true">
Dynamic GSSP not found
</Link>
</div>
);
}
44 changes: 22 additions & 22 deletions tests/pages-page-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ describe("pages page data", () => {
expect(result).toEqual({ kind: "notFound" });
});

it("returns JSON 404 envelope for data requests when getStaticPaths excludes a path", async () => {
it("returns empty JSON 404 for data requests when getStaticPaths excludes a path", async () => {
const result = await resolvePagesPageData(
createOptions({
isDataReq: true,
Expand All @@ -349,7 +349,7 @@ describe("pages page data", () => {
}
expect(result.response.status).toBe(404);
expect(result.response.headers.get("content-type")).toBe("application/json");
await expect(result.response.text()).resolves.toBe("{}");
await expect(result.response.json()).resolves.toEqual({});
});

it("returns JSON 404 envelope for data requests when getStaticProps returns notFound", async () => {
Expand All @@ -370,7 +370,7 @@ describe("pages page data", () => {
}
expect(result.response.status).toBe(404);
expect(result.response.headers.get("content-type")).toBe("application/json");
await expect(result.response.text()).resolves.toBe("{}");
await expect(result.response.json()).resolves.toEqual({ notFound: true });
});

it("returns JSON 404 envelope for data requests when getServerSideProps returns notFound", async () => {
Expand All @@ -391,7 +391,25 @@ describe("pages page data", () => {
}
expect(result.response.status).toBe(404);
expect(result.response.headers.get("content-type")).toBe("application/json");
await expect(result.response.text()).resolves.toBe("{}");
await expect(result.response.json()).resolves.toEqual({ notFound: true });
});

it("allows non-JSON getServerSideProps values during production requests", async () => {
const date = new Date("2026-06-21T00:00:00.000Z");
const result = await resolvePagesPageData(
createOptions({
pageModule: {
async getServerSideProps() {
return { props: { date } };
},
},
}),
);

expect(result).toMatchObject({
kind: "render",
pageProps: { date },
});
});

// Refs #1543: a crawler/bot UA hitting an unlisted `fallback: true` path
Expand Down Expand Up @@ -1294,24 +1312,6 @@ describe("pages page data", () => {
);
});

it("throws a Next.js-style error when getServerSideProps returns non-serializable props", async () => {
await expect(
resolvePagesPageData(
createOptions({
pageModule: {
async getServerSideProps() {
return { props: { fn: () => "nope" } };
},
},
routePattern: "/gssp-bad",
routeUrl: "/gssp-bad",
}),
),
).rejects.toThrow(
/Error serializing `\.fn` returned from `getServerSideProps` in "\/gssp-bad"\.\s*Reason: `function` cannot be serialized as JSON/,
);
});

// ── x-nextjs-deployment-id header ─────────────────────────────────────────
// Mirrors Next.js pages-handler.ts: set x-nextjs-deployment-id on ALL
// `_next/data` exits (success, redirect, notFound) for deployment-skew
Expand Down
27 changes: 27 additions & 0 deletions tests/pages-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vite-plus/test";
import { buildPagesReadinessNextData } from "../packages/vinext/src/server/pages-readiness.js";

describe("Pages readiness serialization", () => {
it("omits false Next.js data-fetching markers", () => {
expect(
buildPagesReadinessNextData({
pageModule: {},
appComponent: null,
hasRewrites: false,
}),
).toEqual({
autoExport: true,
__vinext: { hasRewrites: false },
});
});

it("emits true getServerSideProps marker", () => {
expect(
buildPagesReadinessNextData({
pageModule: { getServerSideProps: async () => ({ props: {} }) },
appComponent: null,
hasRewrites: false,
}),
).toMatchObject({ gssp: true });
});
});
Loading