mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b7ef466742
* fix(pages): preserve app props for gssp requests
* docs(pages): update _next/data comment to reflect full props envelope
* fix(pages-router): preserve _app.getInitialProps during stale ISR regeneration
Stale ISR background regeneration called getStaticProps directly and rendered
HTML and __NEXT_DATA__ with only the page's pageProps, dropping app-level
props from _app.getInitialProps. The foreground path already loads
_app.getInitialProps first and merges its props, but the background
regeneration path skipped that step.
Extract a shared loadPagesAppInitialRenderProps helper from the production
foreground path and reuse it during background regeneration in both
pages-page-data.ts and dev-server.ts. This ensures regenerated pages carry the
same full props envelope as the original render.
Add a regression test in tests/pages-page-data.test.ts.
* fix(pages-router): skip _app.getInitialProps on ISR cache hits
The foreground render path ran _app.getInitialProps before consulting the
ISR cache, so fresh and stale cache hits executed userland App data code
even though the cached HTML was returned verbatim. That wasted work on the
hot path and ran side-effecting App code whose result the cached response
never reflected.
The eager App initial-props load happened up front, ahead of the HIT and
STALE short-circuits. Defer it until the request will actually render:
load it lazily inside the getServerSideProps / getStaticProps /
getInitialProps branches, after the cache short-circuits return.
Stale-while-revalidate already re-runs it inside the regeneration closure,
so regenerated HTML still carries app-level props. The dev SSR handler and
the production page-data resolver are updated in lockstep to preserve
dev/prod parity.
Tests cover the fresh-HIT skip and the stale foreground-skip vs
regeneration-run split at the page-data resolver boundary.
* refactor(pages-router): extract dev App getInitialProps loader into a tested helper
The dev SSR handler ran _app.getInitialProps inside an inline closure that
mutated captured pageProps/renderProps and read res directly, so its lazy
load-only-on-render behavior could only be covered by a heavyweight
integration test that vi.doMock'd the isr-cache and file-matcher modules —
a mock cascade with little signal.
Move the decision into loadDevAppInitialProps in pages-get-initial-props.ts.
It takes the App component, req/res, and an injected AppTree builder (keeping
React out of the module) and returns a discriminated result —
skip / response-sent / render — that the caller applies. The dev-server
closure is now thin glue: build the React tree, delegate, apply the result.
Adds focused unit tests that drive the helper through its real boundary
(genuine getInitialProps functions, plain req/res inputs) — no module mocks:
the skip path, App/page prop merging, pageProps normalization, the
response-sent short-circuit, and the router/ctx wiring passed to userland.
* fix(pages): preserve app props across data requests
* fix(pages): preserve fallback cache expiry
* fix(pages): preserve dev app context during ISR
* fix(pages): hydrate middleware rewrite fallbacks
* fix(pages): retarget middleware data rewrites
* fix(pages): preserve rewrite query on navigation
* fix(pages): finalize rewrite navigation state
* fix(pages): resolve rewrite data targets consistently
* fix(pages): await static props during regeneration
* fix(pages): preserve original query in resolved url
* Revert "fix(pages): await static props during regeneration"
This reverts commit cbdcdf65cd.
* test(app): cover rewrite params in app search
---------
Co-authored-by: James <james@eli.cx>
1514 lines
48 KiB
TypeScript
1514 lines
48 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("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",
|
|
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 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/,
|
|
);
|
|
});
|
|
|
|
// ── 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);
|
|
});
|
|
});
|