diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 74f897259..119064976 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -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", pageModule, params, query, diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 1bc910d13..2b5b6120b 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -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"; + } } // Collect font preloads early so ISR cached responses can include // the Link header (font preloads are module-level state that persists @@ -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 = @@ -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) { diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index c41ca451a..78574d1ba 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -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; }; @@ -121,6 +133,24 @@ export type ResolvePagesPageDataOptions = { expireSeconds?: number, ) => Promise; 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; query: Record; @@ -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 ( @@ -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) { diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index af4dc813b..0985725c1 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -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 | null = null; + const runInFreshUnifiedContext = vi.fn( + async (callback: () => Promise): Promise => callback(), + ) as ResolvePagesPageDataOptions["runInFreshUnifiedContext"]; + const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => { + 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: '
stale
', + 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 = 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 () => {