mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
494e8d2d5b
* fix(pages): render custom errors for notFound results Pages getStaticProps and getServerSideProps notFound HTML results returned the built-in 404 response directly. That skipped the configured pages/404 or pages/_error render path, so _error.getInitialProps never received ctx.req.url or ctx.asPath for the original request. The page-data resolver now returns a typed notFound signal for HTML requests while preserving JSON-shaped 404 data responses. The generated Pages entry rerenders the configured not-found route with the original asPath, and Pages getInitialProps is loaded through a shared typed helper for prod and dev parity. * fix(pages): address PR review feedback on getInitialProps binding, err context, and response-sent handling - Call getInitialProps with component as this receiver - Thread caught errors through to _error.getInitialProps(ctx.err) - Check headersSent || writableEnded in dev server after getInitialProps - Add tests: gSSP notFound, explicit 404, data JSON 404, err on throw, this binding * refactor(pages): use isResponseSent helper in resolvePagesPageData for sent-check consistency The prod block in resolvePagesPageData checked only res.headersSent, while the helper's isResponseSent and the dev server check both headersSent and writableEnded. The prod PagesReqResResponse only declares headersSent (its getter is backed by the ended flag), so this is functionally correct in prod, but the asymmetry is hard to read next to the helper and a future change to the prod response shape would silently break sent-detection. Export isResponseSent from pages-get-initial-props and use it at both gSSP and gIP call sites so the prod resolver reuses the same sent-detection logic as the helper. Also adds tests for the writableEnded-only path and a focused unit test for isResponseSent covering both prod-shaped and node-shaped res mocks. * perf(pages): gate getInitialProps req/res allocation behind presence check The getInitialProps prod block in resolvePagesPageData unconditionally called createGsspReqRes() for every static page without gSP/gSSP, even when the page had no getInitialProps either. This allocated a req/res pair and an unresolved responsePromise that was immediately discarded. Export hasPagesGetInitialProps from pages-get-initial-props.ts and gate the prod block on it, keeping the hot path for plain static pages allocation-free.
887 lines
28 KiB
TypeScript
887 lines
28 KiB
TypeScript
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("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("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("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("{}");
|
|
});
|
|
|
|
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",
|
|
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" },
|
|
revalidate: 15,
|
|
};
|
|
},
|
|
},
|
|
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=0, stale-while-revalidate");
|
|
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: { title: "fresh" },
|
|
}),
|
|
15,
|
|
undefined,
|
|
300,
|
|
);
|
|
expect(isrSet).toHaveBeenCalledWith(
|
|
"pages:/posts/post",
|
|
expect.objectContaining({
|
|
kind: "PAGES",
|
|
html: expect.stringContaining('"__vinext":{"hasMiddleware":true}'),
|
|
pageData: { title: "fresh" },
|
|
}),
|
|
15,
|
|
undefined,
|
|
300,
|
|
);
|
|
});
|
|
|
|
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",
|
|
gsspRes: null,
|
|
isrRevalidateSeconds: 30,
|
|
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 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("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/,
|
|
);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|