mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1500fe61d7
* fix(pages): align prerender functional parity * fix(pages): harden preview bypass comparison
1740 lines
54 KiB
TypeScript
1740 lines
54 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
renderPagesIsrHtml,
|
|
resolvePagesPageData,
|
|
type ResolvePagesPageDataOptions,
|
|
} from "../packages/vinext/src/server/pages-page-data.js";
|
|
|
|
function createOptions(
|
|
overrides: Partial<ResolvePagesPageDataOptions> = {},
|
|
): ResolvePagesPageDataOptions {
|
|
return {
|
|
applyRequestContexts: vi.fn(),
|
|
buildId: "build-123",
|
|
createGsspReqRes() {
|
|
return {
|
|
req: {},
|
|
res: {
|
|
headersSent: false,
|
|
statusCode: 200,
|
|
getHeaders() {
|
|
return {};
|
|
},
|
|
},
|
|
responsePromise: Promise.resolve(new Response("short-circuit", { status: 202 })),
|
|
};
|
|
},
|
|
createPageElement(_pageProps: Record<string, unknown>) {
|
|
return "page";
|
|
},
|
|
fontLinkHeader: "</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
|
|
i18n: {
|
|
locale: "en",
|
|
locales: ["en", "fr"],
|
|
defaultLocale: "en",
|
|
domainLocales: [{ domain: "example.com", defaultLocale: "en", locales: ["en"] }],
|
|
},
|
|
isrCacheKey(_router: string, pathname: string) {
|
|
return `pages:${pathname}`;
|
|
},
|
|
isrGet: vi.fn().mockResolvedValue(null),
|
|
isrSet: vi.fn(async () => {}),
|
|
expireSeconds: 300,
|
|
pageModule: {},
|
|
params: { slug: "post" },
|
|
query: { slug: "post" },
|
|
renderIsrPassToStringAsync: vi.fn(async () => "<div>fresh-body</div>"),
|
|
route: { isDynamic: false },
|
|
routePattern: "/posts/[slug]",
|
|
routeUrl: "/posts/post",
|
|
async runInFreshUnifiedContext<T>(callback: () => Promise<T>): Promise<T> {
|
|
return callback();
|
|
},
|
|
safeJsonStringify(value: unknown) {
|
|
return JSON.stringify(value);
|
|
},
|
|
sanitizeDestination(destination: string) {
|
|
return destination;
|
|
},
|
|
triggerBackgroundRegeneration: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("pages page data", () => {
|
|
it("preserves non-object pageProps returned by custom app getInitialProps", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(function App() {}, {
|
|
getInitialProps() {
|
|
return { appValue: "preserved", pageProps: null };
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
pageProps: {},
|
|
props: { appValue: "preserved", pageProps: null },
|
|
});
|
|
});
|
|
|
|
it("renders fresh ISR HTML while preserving custom document gaps and tail scripts", async () => {
|
|
const html = await renderPagesIsrHtml({
|
|
buildId: "build-123",
|
|
cachedHtml:
|
|
'<!DOCTYPE html><html><head><title>cached</title></head><body><div id="__next"><div>stale-body</div></div><aside data-gap="1"></aside><script>window.__NEXT_DATA__ = {"old":1}</script><script src="/tail.js"></script></body></html>',
|
|
createPageElement(_pageProps: Record<string, unknown>) {
|
|
return "page";
|
|
},
|
|
i18n: {
|
|
locale: "en",
|
|
locales: ["en", "fr"],
|
|
defaultLocale: "en",
|
|
domainLocales: [{ domain: "example.com", defaultLocale: "en", locales: ["en"] }],
|
|
},
|
|
pageProps: { title: "fresh" },
|
|
params: { slug: "post" },
|
|
renderIsrPassToStringAsync: vi.fn(async () => "<div>fresh-body</div>"),
|
|
routePattern: "/posts/[slug]",
|
|
safeJsonStringify(value: unknown) {
|
|
return JSON.stringify(value);
|
|
},
|
|
vinext: { hasMiddleware: true },
|
|
});
|
|
|
|
expect(html).toContain("<div>fresh-body</div>");
|
|
expect(html).toContain('<aside data-gap="1"></aside>');
|
|
expect(html).toContain('<script src="/tail.js"></script>');
|
|
expect(html).toContain('"page":"/posts/[slug]"');
|
|
expect(html).toContain('"slug":"post"');
|
|
expect(html).toContain('"__vinext":{"hasMiddleware":true}');
|
|
});
|
|
|
|
it("preserves custom app props in fallback shells", async () => {
|
|
const AppComponent = Object.assign(function App() {}, {
|
|
getInitialProps() {
|
|
return { appValue: "preserved", pageProps: { discarded: true } };
|
|
},
|
|
});
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent,
|
|
pageModule: {
|
|
default: function Page() {},
|
|
getStaticPaths() {
|
|
return { fallback: true, paths: [] };
|
|
},
|
|
},
|
|
route: { isDynamic: true },
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
isFallback: true,
|
|
pageProps: {},
|
|
props: { appValue: "preserved", pageProps: {} },
|
|
});
|
|
});
|
|
|
|
it("preserves custom app props during stale ISR regeneration", async () => {
|
|
const isrSet = vi.fn(async () => {});
|
|
const createPageElement = vi.fn(() => "page");
|
|
let requestContextsApplied = false;
|
|
let appGetInitialPropsCalls = 0;
|
|
let regenerationPromise: Promise<void> | undefined;
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(function App() {}, {
|
|
getInitialProps() {
|
|
appGetInitialPropsCalls += 1;
|
|
if (appGetInitialPropsCalls > 1) {
|
|
expect(requestContextsApplied).toBe(true);
|
|
}
|
|
return { appValue: "fresh-app", pageProps: { fromApp: true } };
|
|
},
|
|
}),
|
|
applyRequestContexts() {
|
|
requestContextsApplied = true;
|
|
},
|
|
createPageElement,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
cacheControl: { revalidate: 1, expire: undefined },
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<div id="__next">stale</div><script>window.__NEXT_DATA__ = {}</script>',
|
|
pageData: {},
|
|
},
|
|
},
|
|
}),
|
|
isrSet,
|
|
pageModule: {
|
|
default: function Page() {},
|
|
getStaticProps() {
|
|
expect(requestContextsApplied).toBe(true);
|
|
return { props: { fromStatic: true }, revalidate: 10 };
|
|
},
|
|
},
|
|
triggerBackgroundRegeneration: vi.fn((_key, callback) => {
|
|
regenerationPromise = callback();
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
await regenerationPromise;
|
|
expect(createPageElement).toHaveBeenCalledWith({
|
|
appValue: "fresh-app",
|
|
pageProps: { fromApp: true, fromStatic: true },
|
|
});
|
|
expect(isrSet).toHaveBeenCalledWith(
|
|
"pages:/posts/post",
|
|
expect.objectContaining({
|
|
pageData: {
|
|
appValue: "fresh-app",
|
|
pageProps: { fromApp: true, fromStatic: true },
|
|
},
|
|
}),
|
|
10,
|
|
undefined,
|
|
300,
|
|
);
|
|
});
|
|
|
|
it("returns a notFound signal when getStaticPaths excludes a dynamic HTML path", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { slug: "hello-world" } }],
|
|
};
|
|
},
|
|
},
|
|
params: { slug: "missing" },
|
|
query: { slug: "missing" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/missing",
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ kind: "notFound" });
|
|
});
|
|
|
|
it.each([
|
|
["null", null],
|
|
["undefined", undefined],
|
|
["false", false],
|
|
["empty array", []],
|
|
])("accepts optional catch-all %s at the route root", async (_label, slug) => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { slug } }],
|
|
};
|
|
},
|
|
async getStaticProps() {
|
|
return { props: { slug: [] } };
|
|
},
|
|
},
|
|
params: {},
|
|
query: {},
|
|
route: { isDynamic: true },
|
|
routePattern: "/catchall-optional/[[...slug]]",
|
|
routeUrl: "/catchall-optional",
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
pageProps: { slug: [] },
|
|
});
|
|
});
|
|
|
|
it("requires every dynamic key for mixed required and optional catch-all routes", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { slug: [] } }],
|
|
};
|
|
},
|
|
},
|
|
params: { category: "guides" },
|
|
query: { category: "guides" },
|
|
route: { isDynamic: true },
|
|
routePattern: "/[category]/[[...slug]]",
|
|
routeUrl: "/guides",
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ kind: "notFound" });
|
|
});
|
|
|
|
it("accepts mixed required and empty optional catch-all params", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { category: "guides", slug: false } }],
|
|
};
|
|
},
|
|
async getStaticProps() {
|
|
return { props: { category: "guides", slug: [] } };
|
|
},
|
|
},
|
|
params: { category: "guides" },
|
|
query: { category: "guides" },
|
|
route: { isDynamic: true },
|
|
routePattern: "/[category]/[[...slug]]",
|
|
routeUrl: "/guides",
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
pageProps: { category: "guides", slug: [] },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["null", null],
|
|
["undefined", undefined],
|
|
["false", false],
|
|
["empty array", []],
|
|
])("rejects required catch-all %s", async (_label, slug) => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { slug } }],
|
|
};
|
|
},
|
|
},
|
|
params: { slug: ["guide"] },
|
|
query: { slug: ["guide"] },
|
|
route: { isDynamic: true },
|
|
routePattern: "/docs/[...slug]",
|
|
routeUrl: "/docs/guide",
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ kind: "notFound" });
|
|
});
|
|
|
|
it("runs page getInitialProps with the original request URL and asPath", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
asPath: "/3",
|
|
createGsspReqRes() {
|
|
return {
|
|
req: { url: "/3" },
|
|
res: {
|
|
headersSent: false,
|
|
statusCode: 200,
|
|
getHeaders() {
|
|
return {};
|
|
},
|
|
},
|
|
responsePromise: Promise.resolve(new Response("short-circuit", { status: 202 })),
|
|
};
|
|
},
|
|
pageModule: {
|
|
default: Object.assign(
|
|
function Page() {
|
|
return null;
|
|
},
|
|
{
|
|
getInitialProps(context: { req?: { url?: string }; asPath?: string }) {
|
|
return {
|
|
reqUrl: context.req?.url,
|
|
asPath: context.asPath,
|
|
};
|
|
},
|
|
},
|
|
),
|
|
},
|
|
routePattern: "/_error",
|
|
routeUrl: "/3",
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
pageProps: { reqUrl: "/3", asPath: "/3" },
|
|
});
|
|
});
|
|
|
|
it("preserves getInitialProps this binding via component receiver", async () => {
|
|
const Page = Object.assign(
|
|
function Page() {
|
|
return null;
|
|
},
|
|
{
|
|
value: "ok",
|
|
getInitialProps(this: { value: string }) {
|
|
return { value: this.value };
|
|
},
|
|
},
|
|
);
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
createGsspReqRes() {
|
|
return {
|
|
req: {},
|
|
res: {
|
|
headersSent: false,
|
|
statusCode: 200,
|
|
getHeaders() {
|
|
return {};
|
|
},
|
|
},
|
|
responsePromise: Promise.resolve(new Response("short-circuit", { status: 202 })),
|
|
};
|
|
},
|
|
pageModule: {
|
|
default: Page,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
pageProps: { value: "ok" },
|
|
});
|
|
});
|
|
|
|
it("returns a notFound signal when getServerSideProps returns notFound", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ kind: "notFound" });
|
|
});
|
|
|
|
it("returns JSON 404 envelope for data requests when getStaticPaths excludes a path", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: false,
|
|
paths: [{ params: { slug: "hello-world" } }],
|
|
};
|
|
},
|
|
},
|
|
params: { slug: "missing" },
|
|
query: { slug: "missing" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/missing",
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("content-type")).toBe("application/json");
|
|
await expect(result.response.text()).resolves.toBe("{}");
|
|
});
|
|
|
|
it("returns JSON 404 envelope for data requests when getStaticProps returns notFound", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("content-type")).toBe("application/json");
|
|
await expect(result.response.text()).resolves.toBe("{}");
|
|
});
|
|
|
|
it("returns JSON 404 envelope for data requests when getServerSideProps returns notFound", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("content-type")).toBe("application/json");
|
|
await expect(result.response.text()).resolves.toBe("{}");
|
|
});
|
|
|
|
// Refs #1543: a crawler/bot UA hitting an unlisted `fallback: true` path
|
|
// must NOT receive the loading shell — it should render synchronously so
|
|
// the bot indexes real content. Mirrors Next.js's bot check in
|
|
// `.nextjs-ref/packages/next/src/server/route-modules/pages/pages-handler.ts`.
|
|
it("does not set isFallback for bot User-Agent on unlisted fallback: true paths", async () => {
|
|
let gspCalled = false;
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: true,
|
|
paths: [{ params: { slug: "hello-world" } }],
|
|
};
|
|
},
|
|
async getStaticProps({ params }) {
|
|
gspCalled = true;
|
|
return { props: { slug: params?.slug ?? null } };
|
|
},
|
|
},
|
|
params: { slug: "unknown" },
|
|
query: { slug: "unknown" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/unknown",
|
|
userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("render");
|
|
if (result.kind !== "render") throw new Error("expected render result");
|
|
expect(result.isFallback).toBe(false);
|
|
expect(gspCalled).toBe(true);
|
|
expect(result.pageProps).toMatchObject({ slug: "unknown" });
|
|
});
|
|
|
|
it("sets isFallback for normal browser User-Agent on unlisted fallback: true paths", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return {
|
|
fallback: true,
|
|
paths: [{ params: { slug: "hello-world" } }],
|
|
};
|
|
},
|
|
async getStaticProps() {
|
|
throw new Error("getStaticProps should not run on a fallback shell render");
|
|
},
|
|
},
|
|
params: { slug: "unknown" },
|
|
query: { slug: "unknown" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/unknown",
|
|
userAgent:
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36",
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("render");
|
|
if (result.kind !== "render") throw new Error("expected render result");
|
|
expect(result.isFallback).toBe(true);
|
|
expect(result.pageProps).toEqual({});
|
|
});
|
|
|
|
it("skips the fallback shell after its data request generated the path", async () => {
|
|
const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" } }));
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: false,
|
|
value: {
|
|
cacheControl: { revalidate: 60 },
|
|
lastModified: 1,
|
|
value: {
|
|
kind: "PAGES",
|
|
html: "",
|
|
pageData: { pageProps: { slug: "unknown" } },
|
|
generatedFromDataRequest: true,
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return { fallback: true, paths: [] };
|
|
},
|
|
getStaticProps,
|
|
},
|
|
params: { slug: "unknown" },
|
|
query: { slug: "unknown" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/unknown",
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
kind: "render",
|
|
isFallback: false,
|
|
isrRevalidateSeconds: 60,
|
|
pageProps: { slug: "unknown" },
|
|
});
|
|
expect(getStaticProps).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reruns getStaticProps for on-demand revalidation of fallback data", async () => {
|
|
const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" }, revalidate: 60 }));
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isOnDemandRevalidate: true,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: false,
|
|
value: {
|
|
cacheControl: { revalidate: 60 },
|
|
lastModified: 1,
|
|
value: {
|
|
kind: "PAGES",
|
|
html: "",
|
|
pageData: { pageProps: { slug: "cached" } },
|
|
generatedFromDataRequest: true,
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: { getStaticProps },
|
|
}),
|
|
);
|
|
|
|
expect(getStaticProps).toHaveBeenCalledWith(
|
|
expect.objectContaining({ revalidateReason: "on-demand" }),
|
|
);
|
|
expect(result).toMatchObject({ kind: "render", pageProps: { slug: "regenerated" } });
|
|
});
|
|
|
|
it("reruns getStaticProps when generated fallback data is stale", async () => {
|
|
const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" }, revalidate: 60 }));
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
cacheControl: { revalidate: 60 },
|
|
lastModified: 1,
|
|
value: {
|
|
kind: "PAGES",
|
|
html: "",
|
|
pageData: { pageProps: { slug: "cached" } },
|
|
generatedFromDataRequest: true,
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: { getStaticProps },
|
|
}),
|
|
);
|
|
|
|
expect(getStaticProps).toHaveBeenCalledOnce();
|
|
expect(result).toMatchObject({ kind: "render", pageProps: { slug: "regenerated" } });
|
|
});
|
|
|
|
it("short-circuits getServerSideProps responses after res.end()", async () => {
|
|
const responsePromise = Promise.resolve(
|
|
new Response('{"ok":true}', {
|
|
status: 202,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
createGsspReqRes() {
|
|
const res = {
|
|
headersSent: false,
|
|
statusCode: 202,
|
|
getHeaders() {
|
|
return { "content-type": "application/json" };
|
|
},
|
|
};
|
|
return {
|
|
req: { method: "GET" },
|
|
res,
|
|
responsePromise,
|
|
};
|
|
},
|
|
pageModule: {
|
|
async getServerSideProps(context) {
|
|
context.res.headersSent = true;
|
|
return {};
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(202);
|
|
await expect(result.response.text()).resolves.toBe('{"ok":true}');
|
|
});
|
|
|
|
it("short-circuits getServerSideProps responses when only writableEnded is set", async () => {
|
|
const responsePromise = Promise.resolve(
|
|
new Response('{"ok":true}', {
|
|
status: 202,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
createGsspReqRes() {
|
|
const res = {
|
|
headersSent: false,
|
|
writableEnded: true,
|
|
statusCode: 202,
|
|
getHeaders() {
|
|
return { "content-type": "application/json" };
|
|
},
|
|
};
|
|
return {
|
|
req: { method: "GET" },
|
|
res,
|
|
responsePromise,
|
|
};
|
|
},
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return {};
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(202);
|
|
await expect(result.response.text()).resolves.toBe('{"ok":true}');
|
|
});
|
|
|
|
it("short-circuits getInitialProps responses when only writableEnded is set", async () => {
|
|
const responsePromise = Promise.resolve(
|
|
new Response('{"ok":true}', {
|
|
status: 202,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
createGsspReqRes() {
|
|
const res = {
|
|
headersSent: false,
|
|
writableEnded: true,
|
|
statusCode: 202,
|
|
getHeaders() {
|
|
return { "content-type": "application/json" };
|
|
},
|
|
};
|
|
return {
|
|
req: {},
|
|
res,
|
|
responsePromise,
|
|
};
|
|
},
|
|
pageModule: {
|
|
default: Object.assign(
|
|
function Page() {
|
|
return null;
|
|
},
|
|
{
|
|
getInitialProps() {
|
|
return {};
|
|
},
|
|
},
|
|
),
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(202);
|
|
await expect(result.response.text()).resolves.toBe('{"ok":true}');
|
|
});
|
|
|
|
it("serves stale ISR entries immediately and regenerates them through typed helpers", async () => {
|
|
let regenPromise: Promise<void> | null = null;
|
|
const applyRequestContexts = vi.fn();
|
|
const isrSet = vi.fn<ResolvePagesPageDataOptions["isrSet"]>(async () => {});
|
|
const runInFreshUnifiedContext = vi.fn(
|
|
async <T>(callback: () => Promise<T>): Promise<T> => callback(),
|
|
) as ResolvePagesPageDataOptions["runInFreshUnifiedContext"];
|
|
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise<void>) => {
|
|
regenPromise = renderFn();
|
|
});
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
applyRequestContexts,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "stale",
|
|
cacheControl: { revalidate: 15, expire: 300 },
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><head><title>cached</title></head><body><div id="__next"><div>stale-body</div></div><div data-gap="1"></div><script>window.__NEXT_DATA__ = {"old":1}</script><script src="/tail.js"></script></body></html>',
|
|
pageData: { stale: true },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
isrSet,
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return {
|
|
props: { title: "fresh" },
|
|
};
|
|
},
|
|
},
|
|
runInFreshUnifiedContext,
|
|
triggerBackgroundRegeneration,
|
|
vinext: { hasMiddleware: true },
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
|
|
expect(result.response.status).toBe(200);
|
|
expect(result.response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
expect(result.response.headers.get("cache-control")).toBe(
|
|
"s-maxage=15, stale-while-revalidate=285",
|
|
);
|
|
expect(result.response.headers.get("link")).toBe(
|
|
"</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
|
|
);
|
|
await expect(result.response.text()).resolves.toContain("stale-body");
|
|
|
|
expect(triggerBackgroundRegeneration).toHaveBeenCalledOnce();
|
|
if (!regenPromise) {
|
|
throw new Error("expected stale ISR regeneration to start");
|
|
}
|
|
|
|
const pendingRegen: Promise<void> = regenPromise;
|
|
await pendingRegen;
|
|
|
|
expect(runInFreshUnifiedContext).toHaveBeenCalledOnce();
|
|
expect(applyRequestContexts).toHaveBeenCalledOnce();
|
|
expect(isrSet).toHaveBeenCalledWith(
|
|
"pages:/posts/post",
|
|
expect.objectContaining({
|
|
kind: "PAGES",
|
|
html: expect.stringContaining("<div>fresh-body</div>"),
|
|
pageData: { pageProps: { title: "fresh" } },
|
|
}),
|
|
15,
|
|
undefined,
|
|
300,
|
|
);
|
|
expect(isrSet).toHaveBeenCalledWith(
|
|
"pages:/posts/post",
|
|
expect.objectContaining({
|
|
kind: "PAGES",
|
|
html: expect.stringContaining('"__vinext":{"hasMiddleware":true}'),
|
|
pageData: { pageProps: { title: "fresh" } },
|
|
}),
|
|
15,
|
|
undefined,
|
|
300,
|
|
);
|
|
});
|
|
|
|
it("preserves _app.getInitialProps app-level props during stale ISR regeneration", async () => {
|
|
let regenPromise: Promise<void> | null = null;
|
|
const isrSet = vi.fn<ResolvePagesPageDataOptions["isrSet"]>(async () => {});
|
|
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise<void>) => {
|
|
regenPromise = renderFn();
|
|
});
|
|
let capturedRenderProps: Record<string, unknown> | undefined;
|
|
function createPageElement(props: Record<string, unknown>): ReactNode {
|
|
capturedRenderProps = props;
|
|
return null;
|
|
}
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(
|
|
function App() {
|
|
return null;
|
|
},
|
|
{
|
|
getInitialProps() {
|
|
return {
|
|
appProp: "from-app",
|
|
pageProps: {},
|
|
};
|
|
},
|
|
},
|
|
),
|
|
createPageElement,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "stale",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><body><div id="__next"><div>stale-body</div></div><script>window.__NEXT_DATA__ = {"old":1}</script></body></html>',
|
|
pageData: { stale: true },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
isrSet,
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return {
|
|
props: { pageProp: "from-page" },
|
|
revalidate: 60,
|
|
};
|
|
},
|
|
},
|
|
triggerBackgroundRegeneration,
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
|
|
if (!regenPromise) {
|
|
throw new Error("expected stale ISR regeneration to start");
|
|
}
|
|
const pendingRegen: Promise<void> = regenPromise;
|
|
await pendingRegen;
|
|
|
|
expect(capturedRenderProps).toEqual(
|
|
expect.objectContaining({
|
|
appProp: "from-app",
|
|
pageProps: { pageProp: "from-page" },
|
|
}),
|
|
);
|
|
|
|
expect(isrSet).toHaveBeenCalledOnce();
|
|
const regeneratedCacheValue = isrSet.mock.calls[0]?.[1];
|
|
expect(regeneratedCacheValue).toEqual(
|
|
expect.objectContaining({
|
|
kind: "PAGES",
|
|
pageData: {
|
|
appProp: "from-app",
|
|
pageProps: { pageProp: "from-page" },
|
|
},
|
|
}),
|
|
);
|
|
expect(regeneratedCacheValue?.html).toContain('"appProp":"from-app"');
|
|
expect(regeneratedCacheValue?.html).toContain('"pageProp":"from-page"');
|
|
expect(regeneratedCacheValue?.html).toContain('"page":"/posts/[slug]"');
|
|
});
|
|
|
|
it("does not run _app.getInitialProps on a fresh ISR cache HIT", async () => {
|
|
const appGip = vi.fn().mockResolvedValue({
|
|
appProp: "from-app",
|
|
pageProps: {},
|
|
});
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(
|
|
function App() {
|
|
return null;
|
|
},
|
|
{ getInitialProps: appGip },
|
|
),
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: false,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "fresh",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><body><div id="__next"><div>cached-body</div></div></body></html>',
|
|
pageData: { pageProp: "cached" },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { props: { pageProp: "fresh" }, revalidate: 60 };
|
|
},
|
|
},
|
|
triggerBackgroundRegeneration: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(appGip).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("only runs _app.getInitialProps in the stale ISR regeneration path, not on the immediate stale response", async () => {
|
|
let regenPromise: Promise<void> | null = null;
|
|
const isrSet = vi.fn<ResolvePagesPageDataOptions["isrSet"]>(async () => {});
|
|
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise<void>) => {
|
|
regenPromise = renderFn();
|
|
});
|
|
|
|
let insideRegenContext = false;
|
|
let foregroundGipCalls = 0;
|
|
let regenGipCalls = 0;
|
|
const appGip = vi.fn().mockImplementation(() => {
|
|
if (insideRegenContext) {
|
|
regenGipCalls++;
|
|
} else {
|
|
foregroundGipCalls++;
|
|
}
|
|
return Promise.resolve({ appProp: "from-app", pageProps: {} });
|
|
});
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(
|
|
function App() {
|
|
return null;
|
|
},
|
|
{ getInitialProps: appGip },
|
|
),
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "stale",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><body><div id="__next"><div>stale-body</div></div></body></html>',
|
|
pageData: { stale: true },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
isrSet,
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { props: { pageProp: "from-page" }, revalidate: 60 };
|
|
},
|
|
},
|
|
runInFreshUnifiedContext: async (callback) => {
|
|
insideRegenContext = true;
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
insideRegenContext = false;
|
|
}
|
|
},
|
|
triggerBackgroundRegeneration,
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
// App GIP must not run before serving the stale response.
|
|
expect(foregroundGipCalls).toBe(0);
|
|
|
|
if (!regenPromise) {
|
|
throw new Error("expected stale ISR regeneration to start");
|
|
}
|
|
const pendingRegen: Promise<void> = regenPromise;
|
|
await pendingRegen;
|
|
|
|
// App GIP must run exactly once, inside the background regeneration callback.
|
|
expect(regenGipCalls).toBe(1);
|
|
expect(appGip).toHaveBeenCalledOnce();
|
|
expect(isrSet).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("preserves vinext module metadata during stale ISR regeneration", async () => {
|
|
let regenPromise: Promise<void> | null = null;
|
|
const isrSet = vi.fn<ResolvePagesPageDataOptions["isrSet"]>(async () => {});
|
|
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise<void>) => {
|
|
regenPromise = renderFn();
|
|
});
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "stale",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><body><div id="__next"><main>stale 404</main></div><script>window.__NEXT_DATA__ = {"page":"/404","query":{},"props":{"pageProps":{"marker":"stale"}}}</script></body></html>',
|
|
pageData: { marker: "stale" },
|
|
headers: undefined,
|
|
status: 404,
|
|
},
|
|
},
|
|
}),
|
|
isrSet,
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return {
|
|
props: { marker: "fresh" },
|
|
revalidate: 60,
|
|
};
|
|
},
|
|
},
|
|
renderIsrPassToStringAsync: vi.fn(async () => "<main>fresh 404</main>"),
|
|
routePattern: "/404",
|
|
routeUrl: "/missing",
|
|
statusCode: 404,
|
|
triggerBackgroundRegeneration,
|
|
vinext: {
|
|
pageModuleUrl: "/assets/pages/404.js",
|
|
appModuleUrl: "/assets/pages/_app.js",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.status).toBe(404);
|
|
|
|
if (!regenPromise) {
|
|
throw new Error("expected stale ISR regeneration to start");
|
|
}
|
|
const pendingRegen: Promise<void> = regenPromise;
|
|
await pendingRegen;
|
|
|
|
expect(isrSet).toHaveBeenCalledOnce();
|
|
const regeneratedCacheValue = isrSet.mock.calls[0]?.[1];
|
|
expect(regeneratedCacheValue?.html).toContain("<main>fresh 404</main>");
|
|
expect(regeneratedCacheValue?.html).toContain('"__vinext"');
|
|
expect(regeneratedCacheValue?.html).toContain('"pageModuleUrl":"/assets/pages/404.js"');
|
|
expect(regeneratedCacheValue?.html).toContain('"appModuleUrl":"/assets/pages/_app.js"');
|
|
expect(regeneratedCacheValue?.status).toBe(404);
|
|
});
|
|
|
|
it("uses stored cache-control metadata for Pages Router cached HIT responses", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
expireSeconds: 31_536_000,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: false,
|
|
value: {
|
|
cacheControl: { revalidate: 15, expire: 300 },
|
|
lastModified: 1,
|
|
value: {
|
|
kind: "PAGES",
|
|
html: "<html><body>cached</body></html>",
|
|
pageData: { cached: true },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return {
|
|
props: { title: "fresh" },
|
|
revalidate: 15,
|
|
};
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") {
|
|
throw new Error("expected response result");
|
|
}
|
|
expect(result.response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(result.response.headers.get("x-nextjs-cache")).toBe("HIT");
|
|
expect(result.response.headers.get("cache-control")).toBe(
|
|
"s-maxage=15, stale-while-revalidate=285",
|
|
);
|
|
});
|
|
|
|
it("returns normalized render data for cache misses", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return {
|
|
props: { title: "hello" },
|
|
revalidate: 30,
|
|
};
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
kind: "render",
|
|
documentReqRes: null,
|
|
gsspRes: null,
|
|
isrRevalidateSeconds: 30,
|
|
pageProps: { title: "hello" },
|
|
props: { pageProps: { title: "hello" } },
|
|
isFallback: false,
|
|
});
|
|
});
|
|
|
|
// Matches Next.js behavior: for non-dynamic routes, `params` in
|
|
// getServerSideProps context is null (not `{}`).
|
|
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts#L67-L77
|
|
it("passes params: null to getServerSideProps on non-dynamic routes", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getServerSideProps(context) {
|
|
received = context.params;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
params: {},
|
|
query: {},
|
|
route: { isDynamic: false },
|
|
routePattern: "/",
|
|
routeUrl: "/",
|
|
}),
|
|
);
|
|
|
|
expect(received).toBeNull();
|
|
});
|
|
|
|
it("passes the matched params object to getServerSideProps on dynamic routes", async () => {
|
|
let received: unknown = null;
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getServerSideProps(context) {
|
|
received = context.params;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
params: { id: "123" },
|
|
query: { id: "123" },
|
|
route: { isDynamic: true },
|
|
routePattern: "/[id]",
|
|
routeUrl: "/123",
|
|
}),
|
|
);
|
|
|
|
expect(received).toEqual({ id: "123" });
|
|
});
|
|
|
|
// `getStaticProps` receives `context.revalidateReason` describing why the
|
|
// function was called. Mirrors Next.js's render.tsx — see
|
|
// `.nextjs-ref/test/e2e/revalidate-reason/revalidate-reason.test.ts` for
|
|
// the authoritative tri-state assertions.
|
|
it("passes revalidateReason: 'build' to getStaticProps during build-time prerendering", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
isBuildTimePrerendering: true,
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = context.revalidateReason;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(received).toBe("build");
|
|
});
|
|
|
|
it("passes revalidateReason: 'on-demand' to getStaticProps when on-demand revalidation is signalled", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
isOnDemandRevalidate: true,
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = context.revalidateReason;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(received).toBe("on-demand");
|
|
});
|
|
|
|
it("passes preview context to getStaticProps and bypasses fresh ISR hits", async () => {
|
|
let received: unknown = "untouched";
|
|
const getStaticProps = vi.fn(async (context) => {
|
|
received = {
|
|
draftMode: context.draftMode,
|
|
preview: context.preview,
|
|
previewData: context.previewData,
|
|
};
|
|
return { props: { fromPreview: true } };
|
|
});
|
|
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: false,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "hit",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: "<html>cached</html>",
|
|
pageData: { pageProps: { cached: true } },
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: { getStaticProps },
|
|
previewData: { hello: "world" },
|
|
}),
|
|
);
|
|
|
|
expect(getStaticProps).toHaveBeenCalledOnce();
|
|
expect(received).toEqual({
|
|
draftMode: true,
|
|
preview: true,
|
|
previewData: { hello: "world" },
|
|
});
|
|
expect(result.kind).toBe("render");
|
|
if (result.kind !== "render") throw new Error("expected render");
|
|
expect(result.pageProps).toEqual({ fromPreview: true });
|
|
});
|
|
|
|
it("disables preview context for on-demand getStaticProps regeneration", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
isOnDemandRevalidate: true,
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = {
|
|
draftMode: context.draftMode,
|
|
preview: context.preview,
|
|
previewData: context.previewData,
|
|
revalidateReason: context.revalidateReason,
|
|
};
|
|
return { props: {} };
|
|
},
|
|
},
|
|
previewData: { hello: "world" },
|
|
}),
|
|
);
|
|
|
|
expect(received).toEqual({
|
|
draftMode: undefined,
|
|
preview: undefined,
|
|
previewData: undefined,
|
|
revalidateReason: "on-demand",
|
|
});
|
|
});
|
|
|
|
it("passes preview context to getServerSideProps", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getServerSideProps(context) {
|
|
received = {
|
|
draftMode: context.draftMode,
|
|
preview: context.preview,
|
|
previewData: context.previewData,
|
|
};
|
|
return { props: {} };
|
|
},
|
|
},
|
|
previewData: "draft",
|
|
}),
|
|
);
|
|
|
|
expect(received).toEqual({
|
|
draftMode: true,
|
|
preview: true,
|
|
previewData: "draft",
|
|
});
|
|
});
|
|
|
|
it("passes revalidateReason: 'stale' to getStaticProps for runtime cache-miss requests", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = context.revalidateReason;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(received).toBe("stale");
|
|
});
|
|
|
|
it("passes revalidateReason: 'stale' to getStaticProps during stale-while-revalidate regeneration", async () => {
|
|
let received: unknown = "untouched";
|
|
let regenPromise: Promise<void> | null = null;
|
|
const runInFreshUnifiedContext = vi.fn(
|
|
async <T>(callback: () => Promise<T>): Promise<T> => callback(),
|
|
) as ResolvePagesPageDataOptions["runInFreshUnifiedContext"];
|
|
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise<void>) => {
|
|
regenPromise = renderFn();
|
|
});
|
|
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
// Even when the dispatch itself is a build-time prerender, the SWR
|
|
// refresh path is still a stale regeneration — matches Next.js.
|
|
isBuildTimePrerendering: true,
|
|
isrGet: vi.fn().mockResolvedValue({
|
|
isStale: true,
|
|
value: {
|
|
lastModified: 1,
|
|
cacheState: "stale",
|
|
value: {
|
|
kind: "PAGES",
|
|
html: '<!DOCTYPE html><html><body><div id="__next"><div>stale</div></div><script>window.__NEXT_DATA__ = {}</script></body></html>',
|
|
pageData: {},
|
|
headers: undefined,
|
|
status: undefined,
|
|
},
|
|
},
|
|
}),
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = context.revalidateReason;
|
|
return { props: {}, revalidate: 5 };
|
|
},
|
|
},
|
|
runInFreshUnifiedContext,
|
|
triggerBackgroundRegeneration,
|
|
}),
|
|
);
|
|
|
|
expect(triggerBackgroundRegeneration).toHaveBeenCalledOnce();
|
|
if (!regenPromise) {
|
|
throw new Error("expected stale regeneration to start");
|
|
}
|
|
const pendingRegen: Promise<void> = regenPromise;
|
|
await pendingRegen;
|
|
|
|
expect(received).toBe("stale");
|
|
});
|
|
|
|
// Mirrors Next.js's `isSerializableProps` check from render.tsx (~line 982).
|
|
// Without this validation vinext silently rendered an empty page for
|
|
// non-JSON values like `new Date()`. Tracked in vinext#1478.
|
|
// See .nextjs-ref/packages/next/src/lib/is-serializable-props.ts and the
|
|
// `non-json`/`non-json-blocking` cases in .nextjs-ref/test/e2e/prerender.test.ts.
|
|
it("throws a Next.js-style error when getStaticProps returns non-serializable props", async () => {
|
|
await expect(
|
|
resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { props: { date: new Date(0) } };
|
|
},
|
|
},
|
|
routePattern: "/non-json",
|
|
routeUrl: "/non-json",
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
/Error serializing `\.date` returned from `getStaticProps` in "\/non-json"\.\s*Reason: `object` \("\[object Date\]"\) cannot be serialized as JSON/,
|
|
);
|
|
});
|
|
|
|
it("allows non-serializable getStaticProps props when production SSR validation is disabled", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { props: { date: new Date(0) } };
|
|
},
|
|
},
|
|
routePattern: "/non-json",
|
|
routeUrl: "/non-json",
|
|
validatePropsSerialization: false,
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("render");
|
|
if (result.kind !== "render") throw new Error("expected render");
|
|
expect(result.pageProps.date).toBeInstanceOf(Date);
|
|
});
|
|
|
|
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
|
|
// protection. Fixes #1829.
|
|
|
|
it("includes x-nextjs-deployment-id on notFound data response when deploymentId is set", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
deploymentId: "test-deploy-abc",
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc");
|
|
});
|
|
|
|
it("includes x-nextjs-deployment-id on notFound data response from getStaticProps", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
deploymentId: "test-deploy-abc",
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc");
|
|
});
|
|
|
|
it("includes x-nextjs-deployment-id on notFound data response from getStaticPaths exclusion", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
deploymentId: "test-deploy-abc",
|
|
pageModule: {
|
|
async getStaticPaths() {
|
|
return { fallback: false, paths: [{ params: { slug: "known" } }] };
|
|
},
|
|
},
|
|
params: { slug: "unknown" },
|
|
query: { slug: "unknown" },
|
|
route: { isDynamic: true },
|
|
routeUrl: "/posts/unknown",
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(404);
|
|
expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc");
|
|
});
|
|
|
|
it("includes x-nextjs-deployment-id on redirect data response from getServerSideProps", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
AppComponent: Object.assign(function App() {}, {
|
|
getInitialProps() {
|
|
return { appValue: "preserved", pageProps: {} };
|
|
},
|
|
}),
|
|
isDataReq: true,
|
|
deploymentId: "test-deploy-abc",
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return { redirect: { destination: "/new-page", permanent: false } };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(200);
|
|
expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc");
|
|
const body = (await result.response.json()) as {
|
|
__N_SSP?: boolean;
|
|
appValue?: string;
|
|
pageProps: Record<string, unknown>;
|
|
};
|
|
expect(body.__N_SSP).toBe(true);
|
|
expect(body.appValue).toBe("preserved");
|
|
expect(body.pageProps.__N_REDIRECT).toBe("/new-page");
|
|
});
|
|
|
|
it("includes x-nextjs-deployment-id on redirect data response from getStaticProps", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
deploymentId: "test-deploy-abc",
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { redirect: { destination: "/new-page", permanent: false } };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(200);
|
|
expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc");
|
|
const body = (await result.response.json()) as { pageProps: Record<string, unknown> };
|
|
expect(body.pageProps.__N_REDIRECT).toBe("/new-page");
|
|
});
|
|
|
|
it("omits x-nextjs-deployment-id on redirect/notFound data responses when deploymentId is not set", async () => {
|
|
const notFoundResult = await resolvePagesPageData(
|
|
createOptions({
|
|
isDataReq: true,
|
|
// deploymentId intentionally omitted
|
|
pageModule: {
|
|
async getServerSideProps() {
|
|
return { notFound: true };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(notFoundResult.kind).toBe("response");
|
|
if (notFoundResult.kind !== "response") throw new Error("expected response");
|
|
expect(notFoundResult.response.headers.get("x-nextjs-deployment-id")).toBeNull();
|
|
});
|
|
|
|
// Redirect and notFound short-circuits must continue to work even if the
|
|
// page also returns `props` — mirrors Next.js, which only validates when
|
|
// !metadata.isRedirect && !metadata.isNotFound.
|
|
it("does not throw on getStaticProps redirect even when props would be invalid", async () => {
|
|
const result = await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps() {
|
|
return { redirect: { destination: "/elsewhere", permanent: false } };
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.kind).toBe("response");
|
|
if (result.kind !== "response") throw new Error("expected response");
|
|
expect(result.response.status).toBe(307);
|
|
expect(result.response.headers.get("location")).toBe("/elsewhere");
|
|
});
|
|
|
|
// Matches Next.js behavior: for non-dynamic routes, `params` in
|
|
// getStaticProps context is null (not `{}`).
|
|
it("passes params: null to getStaticProps on non-dynamic routes", async () => {
|
|
let received: unknown = "untouched";
|
|
await resolvePagesPageData(
|
|
createOptions({
|
|
pageModule: {
|
|
async getStaticProps(context) {
|
|
received = context.params;
|
|
return { props: {} };
|
|
},
|
|
},
|
|
params: {},
|
|
query: {},
|
|
route: { isDynamic: false },
|
|
routePattern: "/",
|
|
routeUrl: "/",
|
|
}),
|
|
);
|
|
|
|
expect(received).toBeNull();
|
|
});
|
|
|
|
it("isResponseSent detects both headersSent and writableEnded", async () => {
|
|
const { isResponseSent } =
|
|
await import("../packages/vinext/src/server/pages-get-initial-props.js");
|
|
|
|
expect(isResponseSent({ headersSent: true })).toBe(true);
|
|
expect(isResponseSent({ writableEnded: true })).toBe(true);
|
|
expect(isResponseSent({ headersSent: true, writableEnded: true })).toBe(true);
|
|
expect(isResponseSent({ headersSent: false })).toBe(false);
|
|
expect(isResponseSent({ writableEnded: false })).toBe(false);
|
|
expect(isResponseSent({})).toBe(false);
|
|
expect(isResponseSent(undefined)).toBe(false);
|
|
expect(isResponseSent(null)).toBe(false);
|
|
// The prod PagesReqResResponse type only declares headersSent; the helper
|
|
// must not throw or treat the absent writableEnded as truthy.
|
|
const prodShaped: { headersSent: boolean } = { headersSent: false };
|
|
expect(isResponseSent(prodShaped)).toBe(false);
|
|
prodShaped.headersSent = true;
|
|
expect(isResponseSent(prodShaped)).toBe(true);
|
|
});
|
|
});
|