Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,13 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
isrGet,
isrSet,
expireSeconds: vinextConfig.expireTime,
// The vinext build phase boots the prod server with VINEXT_PRERENDER=1
// and fetches every statically-generated page through it. That hit is
// the "build" prerender for revalidateReason; runtime hits are not.
// Mirrors Next.js's \`renderOpts.isBuildTimePrerendering\`. See
// \`.nextjs-ref/packages/next/src/server/render.tsx\` and
// \`packages/vinext/src/build/prerender.ts\`.
isBuildTimePrerendering: typeof process !== "undefined" && process.env && process.env.VINEXT_PRERENDER === "1",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isOnDemandRevalidate is not passed here, so the "on-demand" branch in resolvePagesPageData is currently unreachable. That's fine as forward-looking plumbing, but consider adding a brief comment noting that on-demand revalidation (res.revalidate()) is not yet wired up — it will save someone from wondering why it's never set.

pageModule,
params,
query,
Expand Down
23 changes: 22 additions & 1 deletion packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,19 @@ export function createSSRHandler(
gsspExtraHeaders[key] = String(val);
}
}

// Default Cache-Control for getServerSideProps responses, matching
// Next.js's pages-handler.ts (revalidate: 0 → getCacheControlHeader).
// Skip when gSSP already set one via res.setHeader (case-insensitive)
// or when ISR is layered on top below — that branch overwrites this
// default with the ISR cache-control. Fixes #1461.
const hasUserCacheControl = Object.keys(gsspExtraHeaders).some(
(k) => k.toLowerCase() === "cache-control",
);
if (!hasUserCacheControl) {
gsspExtraHeaders["Cache-Control"] =
"private, no-cache, no-store, max-age=0, must-revalidate";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This default Cache-Control fix is correct for dev, but the production path (renderPagesPageResponse in pages-page-response.ts) doesn't set any default Cache-Control for gSSP responses. This creates a dev/prod parity gap — dev returns private, no-cache, no-store, ... while prod returns no Cache-Control at all.

The fix should also go in pages-page-response.ts (after the scriptNonce and isrRevalidateSeconds branches) and the isDataReq JSON short-circuit in pages-server-entry.ts.

Also, this is an unrelated fix (referencing #1461) bundled into a PR whose title and description only mention revalidateReason (#1462). Consider either splitting it into its own PR or updating the PR description to mention both fixes.

}
// Collect font preloads early so ISR cached responses can include
// the Link header (font preloads are module-level state that persists
Expand Down Expand Up @@ -673,6 +686,9 @@ export function createSSRHandler(
locale: locale ?? currentDefaultLocale,
locales: i18nConfig?.locales,
defaultLocale: currentDefaultLocale,
// Stale-while-revalidate background regeneration — mirrors
// Next.js `render.tsx`'s `revalidateReason` resolution.
revalidateReason: "stale",
});
if (freshResult && "props" in freshResult) {
const revalidate =
Expand Down Expand Up @@ -796,12 +812,17 @@ export function createSSRHandler(
return;
}

// Cache miss — call getStaticProps normally
// Cache miss — call getStaticProps normally.
// Dev has no build-time prerender phase, so every dev hit is
// treated as a stale-while-revalidate refresh — mirrors Next.js
// `render.tsx` (`isBuildTimeSSG ? "build" : "stale"`).
// See `.nextjs-ref/test/e2e/revalidate-reason/revalidate-reason.test.ts`.
const context = {
params: userFacingParams,
locale: locale ?? currentDefaultLocale,
locales: i18nConfig?.locales,
defaultLocale: currentDefaultLocale,
revalidateReason: "stale" as const,
};
const result = await pageModule.getStaticProps(context);
if (result && "props" in result) {
Expand Down
46 changes: 46 additions & 0 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ export type PagesPageModule = {
locale?: string;
locales?: string[];
defaultLocale?: string;
/**
* Indicates why `getStaticProps` was invoked.
*
* - `"build"`: initial build-time prerender (before runtime traffic).
* - `"on-demand"`: triggered by `res.revalidate()` from an API route.
* - `"stale"`: stale-while-revalidate background regeneration.
*
* Mirrors Next.js `render.tsx`'s `revalidateReason` on the
* `GetStaticPropsContext` type — see
* `.nextjs-ref/packages/next/src/types.ts`.
*/
revalidateReason?: "build" | "on-demand" | "stale";
}) => Promise<PagesPagePropsResult> | PagesPagePropsResult;
};

Expand Down Expand Up @@ -121,6 +133,24 @@ export type ResolvePagesPageDataOptions = {
expireSeconds?: number,
) => Promise<void>;
expireSeconds?: number;
/**
* When true, this dispatch corresponds to a build-time prerender (the
* `vinext` build phase fetches each statically generated page through the
* production server). Maps to `revalidateReason: "build"` when
* `getStaticProps` is invoked. Mirrors Next.js's
* `renderOpts.isBuildTimePrerendering` flag — see
* `.nextjs-ref/packages/next/src/server/render.tsx`.
*/
isBuildTimePrerendering?: boolean;
/**
* When true, this dispatch was triggered by an on-demand revalidation
* request (e.g. `res.revalidate()` in a Pages Router API route, or an
* equivalent webhook). Maps to `revalidateReason: "on-demand"` when
* `getStaticProps` is invoked. Mirrors Next.js's
* `renderOpts.isOnDemandRevalidate` flag — see
* `.nextjs-ref/packages/next/src/server/render.tsx`.
*/
isOnDemandRevalidate?: boolean;
pageModule: PagesPageModule;
params: Record<string, unknown>;
query: Record<string, unknown>;
Expand Down Expand Up @@ -457,6 +487,11 @@ export async function resolvePagesPageData(
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
// Background regeneration for an entry that is already in the
// cache is always a stale-while-revalidate refresh — mirrors
// Next.js `render.tsx` (`isBuildTimeSSG ? "build" : "stale"`,
// and we're not at build time here).
revalidateReason: "stale",
});

if (
Expand Down Expand Up @@ -512,6 +547,17 @@ export async function resolvePagesPageData(
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
// Maps Next.js's resolution in `render.tsx`:
// isOnDemandRevalidate ? "on-demand"
// : isBuildTimeSSG ? "build"
// : "stale"
// We pick "stale" as the default at runtime so existing-but-missing
// (cache evicted) entries surface as a regeneration rather than a build.
revalidateReason: options.isOnDemandRevalidate
? "on-demand"
: options.isBuildTimePrerendering
? "build"
: "stale",
});

if (result?.props) {
Expand Down
104 changes: 104 additions & 0 deletions tests/pages-page-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,110 @@ describe("pages page data", () => {
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");
});

// 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 () => {
Expand Down
Loading