diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts
index 8cbdc0316..12e4496f8 100644
--- a/packages/vinext/src/build/prerender.ts
+++ b/packages/vinext/src/build/prerender.ts
@@ -44,6 +44,7 @@ import {
VINEXT_PRERENDER_CACHE_LIFE_HEADER,
VINEXT_PRERENDER_METADATA_ROUTES_PATH,
VINEXT_PRERENDER_RENDER_ERROR_HEADER,
+ VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER,
VINEXT_PRERENDER_ROUTE_PARAMS_HEADER,
VINEXT_PRERENDER_SECRET_HEADER,
VINEXT_PRERENDER_SPECULATIVE_HEADER,
@@ -209,6 +210,28 @@ type PrerenderProgressCallback = (update: {
status: PrerenderRouteResult["status"];
}) => void;
+function nonCacheablePagesResult(
+ mode: PrerenderOptions["mode"],
+ route: string,
+ path = route,
+): PrerenderRouteResult {
+ if (mode === "export") {
+ return {
+ route,
+ status: "error",
+ error:
+ "Page returned an explicitly non-cacheable response which is not supported with output: 'export'",
+ };
+ }
+
+ return {
+ route,
+ status: "skipped",
+ reason: "dynamic",
+ ...(path !== route ? { path } : {}),
+ };
+}
+
type PrerenderOptions = {
/**
* 'default' — prerender static/ISR routes; skip SSR routes
@@ -950,40 +973,57 @@ export async function prerenderPages({
);
const htmlFullPath = path.join(outDir, htmlOutputPath);
- if (response.status >= 300 && response.status < 400) {
- // getStaticProps returned a redirect — emit a meta-refresh HTML page
- // so the static export can represent the redirect without a server.
- const dest = response.headers.get("location") ?? "/";
- const escapedDest = dest
- .replace(/&/g, "&")
- .replace(/"/g, """)
- .replace(//g, ">");
- const html = `
`;
- fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true });
- fs.writeFileSync(htmlFullPath, html, "utf-8");
- outputFiles.push(htmlOutputPath);
+ const isRedirectResponse = response.status >= 300 && response.status < 400;
+ const isDynamicResponse =
+ (response.ok || isRedirectResponse) &&
+ response.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER) === "1";
+ if (isDynamicResponse) {
+ await response.body?.cancel();
+ result = nonCacheablePagesResult(mode, route.pattern, urlPath);
+ } else if (!isRedirectResponse && !response.ok) {
+ const fatal = response.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER) === "1";
+ await response.body?.cancel();
+ const renderError = new Error(`renderPage returned ${response.status} for ${urlPath}`);
+ result = {
+ route: route.pattern,
+ status: "error",
+ error: config.enablePrerenderSourceMaps
+ ? getErrorMessageWithStack(renderError)
+ : renderError.message,
+ ...(fatal ? { fatal: true as const } : {}),
+ };
} else {
- if (!response.ok) {
- throw new Error(`renderPage returned ${response.status} for ${urlPath}`);
+ if (isRedirectResponse) {
+ // getStaticProps returned a redirect — emit a meta-refresh HTML page
+ // so the static export can represent the redirect without a server.
+ const dest = response.headers.get("location") ?? "/";
+ const escapedDest = dest
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+ const html = ``;
+ fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true });
+ fs.writeFileSync(htmlFullPath, html, "utf-8");
+ outputFiles.push(htmlOutputPath);
+ } else {
+ const html = await response.text();
+ fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true });
+ fs.writeFileSync(htmlFullPath, html, "utf-8");
+ outputFiles.push(htmlOutputPath);
}
- const html = await response.text();
- fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true });
- fs.writeFileSync(htmlFullPath, html, "utf-8");
- outputFiles.push(htmlOutputPath);
+ result = {
+ route: route.pattern,
+ status: "rendered",
+ outputFiles,
+ revalidate,
+ // Pages Router cache metadata comes only from getStaticProps.revalidate;
+ // Next.js applies expireTime as the fallback when no route expire exists.
+ ...(typeof revalidate === "number" ? { expire: config.expireTime } : {}),
+ router: "pages",
+ ...(urlPath !== route.pattern ? { path: urlPath } : {}),
+ };
}
-
- result = {
- route: route.pattern,
- status: "rendered",
- outputFiles,
- revalidate,
- // Pages Router cache metadata comes only from getStaticProps.revalidate;
- // Next.js applies expireTime as the fallback when no route expire exists.
- ...(typeof revalidate === "number" ? { expire: config.expireTime } : {}),
- router: "pages",
- ...(urlPath !== route.pattern ? { path: urlPath } : {}),
- };
} catch (e) {
renderPool?.recordRenderError(e);
const err = e as Error;
@@ -1016,15 +1056,32 @@ export async function prerenderPages({
try {
const notFoundRes = await renderPage(hasCustom404 ? "/404" : NOT_FOUND_SENTINEL_PATH);
const contentType = notFoundRes.headers.get("content-type") ?? "";
- if (notFoundRes.status === 404 && contentType.includes("text/html")) {
- const html404 = await notFoundRes.text();
+ if (!notFoundRes.ok && notFoundRes.status !== 404) {
+ const fatal = notFoundRes.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER) === "1";
+ await notFoundRes.body?.cancel();
+ const renderError = new Error(`renderPage returned ${notFoundRes.status} for /404`);
results.push({
route: "/404",
- status: "rendered",
- outputFiles: emitStatic404Files(outDir, html404, config.trailingSlash),
- revalidate: false,
- router: "pages",
+ status: "error",
+ error: config.enablePrerenderSourceMaps
+ ? getErrorMessageWithStack(renderError)
+ : renderError.message,
+ ...(fatal ? { fatal: true as const } : {}),
});
+ } else if (notFoundRes.status === 404 && contentType.includes("text/html")) {
+ if (notFoundRes.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER) === "1") {
+ await notFoundRes.body?.cancel();
+ results.push(nonCacheablePagesResult(mode, "/404"));
+ } else {
+ const html404 = await notFoundRes.text();
+ results.push({
+ route: "/404",
+ status: "rendered",
+ outputFiles: emitStatic404Files(outDir, html404, config.trailingSlash),
+ revalidate: false,
+ router: "pages",
+ });
+ }
}
} catch (e) {
// No custom 404. When the render-worker pool is active, a transport
diff --git a/packages/vinext/src/server/app-prerender-endpoints.ts b/packages/vinext/src/server/app-prerender-endpoints.ts
index 096460967..e83596163 100644
--- a/packages/vinext/src/server/app-prerender-endpoints.ts
+++ b/packages/vinext/src/server/app-prerender-endpoints.ts
@@ -5,6 +5,10 @@ import {
VINEXT_PRERENDER_STATIC_PARAMS_PATH,
} from "./headers.js";
import { notFoundResponse } from "./http-error-responses.js";
+import {
+ assertPagesDataExportCompatibility,
+ type PagesDataExportModule,
+} from "./pages-data-export-compatibility.js";
import type { RootParams } from "vinext/shims/root-params";
type GenerateStaticParams = (args: { params: RootParams }) => unknown;
@@ -14,7 +18,7 @@ export type AppPrerenderRootParamNamesMap = Record unknown;
};
};
@@ -116,6 +120,9 @@ async function handlePagesStaticPathsEndpoint(
try {
const pageRoutes = await options.loadPagesRoutes?.();
const route = findPageRoute(pageRoutes, pattern);
+ if (route?.module) {
+ assertPagesDataExportCompatibility(route.module, pattern);
+ }
const getStaticPaths = route?.module?.getStaticPaths;
if (typeof getStaticPaths !== "function") {
return jsonNullResponse();
diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts
index e72c116f0..8f0a33446 100644
--- a/packages/vinext/src/server/dev-server.ts
+++ b/packages/vinext/src/server/dev-server.ts
@@ -61,6 +61,7 @@ import {
type PagesRedirectResult,
type PagesStaticPathsEntry,
} from "./pages-page-data.js";
+import { assertPagesDataExportCompatibility } from "./pages-data-export-compatibility.js";
import { sanitizeDestination } from "../config/config-matchers.js";
import { collectPagesDevInitialStylesheetHeadHTML } from "./pages-dev-stylesheets.js";
import { createPagesDevModuleUrl } from "./pages-dev-module-url.js";
@@ -937,6 +938,10 @@ export function createSSRHandler(
// and `useRouter().isFallback === true`, matching Next.js render.tsx.
let isFallbackRender = false;
+ if (typeof pageModule.getStaticProps === "function") {
+ assertPagesDataExportCompatibility(pageModule, patternToNextFormat(route.pattern));
+ }
+
// Handle getStaticPaths for dynamic routes: validate the path,
// respect `fallback: false` (return 404 for unlisted paths), and
// render the loading shell for unlisted paths under `fallback: true`.
@@ -1215,6 +1220,7 @@ export function createSSRHandler(
const scriptNonce = getScriptNonceFromNodeHeaderSources(req.headers, responseHeaders);
if (typeof pageModule.getStaticProps === "function" && !isFallbackRender) {
+ const routePattern = patternToNextFormat(route.pattern);
// An authenticated res.revalidate() request executes GSP once with the
// on-demand reason, but Pages response entries are never read or
// written in development. Ordinary requests independently rerun GSP
@@ -1258,7 +1264,6 @@ export function createSSRHandler(
}
const result = await pageModule.getStaticProps(context);
- const routePattern = patternToNextFormat(route.pattern);
assertPages404DoesNotReturnNotFound(routePattern, result);
if (result) {
staticPropsRevalidateSeconds = resolvePagesRevalidateSeconds(result, routePattern);
diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts
index abd7e8677..1d8ba4cf9 100644
--- a/packages/vinext/src/server/headers.ts
+++ b/packages/vinext/src/server/headers.ts
@@ -99,6 +99,9 @@ export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "x-vinext-prerender-cache-life
/** Marks a local prerender-server 500 that originated from a thrown render error. */
export const VINEXT_PRERENDER_RENDER_ERROR_HEADER = "x-vinext-prerender-render-error";
+/** Marks a Pages prerender response whose request-derived App props prohibit reuse. */
+export const VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER = "x-vinext-prerender-shared-cache-bypass";
+
/** Internal marker persisted only inside metadata-route APP_ROUTE cache values. */
export const VINEXT_METADATA_ROUTE_CACHE_HEADER = "x-vinext-metadata-route-cache";
diff --git a/packages/vinext/src/server/pages-data-export-compatibility.ts b/packages/vinext/src/server/pages-data-export-compatibility.ts
new file mode 100644
index 000000000..e71f818eb
--- /dev/null
+++ b/packages/vinext/src/server/pages-data-export-compatibility.ts
@@ -0,0 +1,35 @@
+import { hasPagesGetInitialProps } from "./pages-get-initial-props.js";
+import { VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER } from "./headers.js";
+
+const SSG_GET_INITIAL_PROPS_CONFLICT =
+ "You can not use getInitialProps with getStaticProps. To use SSG, please remove your getInitialProps";
+
+export type PagesDataExportModule = {
+ default?: unknown;
+ getStaticProps?: unknown;
+};
+
+export class PagesDataExportCompatibilityError extends Error {
+ override name = "PagesDataExportCompatibilityError";
+}
+
+export function markPagesPrerenderSharedCacheBypass(headers: Headers): void {
+ if (process.env.VINEXT_PRERENDER === "1") {
+ headers.set(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, "1");
+ }
+}
+
+/** Reject Pages data-export combinations that Next.js does not allow. */
+export function assertPagesDataExportCompatibility(
+ pageModule: PagesDataExportModule,
+ routePattern: string,
+): void {
+ if (
+ typeof pageModule.getStaticProps === "function" &&
+ hasPagesGetInitialProps(pageModule.default)
+ ) {
+ throw new PagesDataExportCompatibilityError(
+ `${SSG_GET_INITIAL_PROPS_CONFLICT} ${routePattern}`,
+ );
+ }
+}
diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts
index 667538ec4..faf065e07 100644
--- a/packages/vinext/src/server/pages-page-data.ts
+++ b/packages/vinext/src/server/pages-page-data.ts
@@ -10,7 +10,7 @@ import type {
CacheControlMetadata,
} from "vinext/shims/cache-handler";
import { applyCdnResponseHeaders } from "./cache-control.js";
-import { buildMissIsrCacheControl, decideIsr } from "./isr-decision.js";
+import { buildMissIsrCacheControl, decideIsr, ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js";
import { buildCacheStateHeaders } from "./cache-headers.js";
import {
buildPagesCacheValue,
@@ -43,6 +43,7 @@ import { isBotUserAgent } from "../utils/html-limited-bots.js";
import { isUnknownRecord } from "../utils/record.js";
import { isDangerousScheme } from "vinext/shims/url-safety";
import { encodeCacheTag } from "../utils/encode-cache-tag.js";
+import { assertPagesDataExportCompatibility } from "./pages-data-export-compatibility.js";
export type PagesRedirectResult = {
destination: string;
@@ -58,7 +59,6 @@ export type ResolvedPagesRedirect = {
};
const ALLOWED_PAGES_REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
-
/** Headers that are part of a cached Pages representation, never request state. */
function isCachedPagesRepresentationHeader(name: string): boolean {
const lowerName = name.toLowerCase();
@@ -382,6 +382,11 @@ type ResolvePagesPageDataRenderResult = {
gsspRes: PagesGsspResponse | null;
isrRevalidateSeconds: number | false | null;
isrExpireSeconds?: number;
+ /**
+ * True when a getStaticProps render carries request-derived App props. The
+ * caller must emit a private, no-store policy so no shared cache stores it.
+ */
+ bypassSharedCache: boolean;
pageProps: Record;
props: PagesRenderProps;
/**
@@ -402,6 +407,8 @@ type ResolvePagesPageDataResponseResult = {
type ResolvePagesPageDataNotFoundResult = {
kind: "notFound";
+ /** The source getStaticProps route carried request-derived App props. */
+ bypassSharedCache?: boolean;
/** Headers set by getServerSideProps before it returned notFound. */
responseHeaders?: Record;
/** Current getStaticProps cache lifetime, when this is an SSG result. */
@@ -456,6 +463,19 @@ function buildPagesNotFoundResult(
};
}
+function buildRequestAwarePagesNotFoundResult(
+ options: Pick,
+): ResolvePagesPageDataResponseResult | ResolvePagesPageDataNotFoundResult {
+ const result = buildPagesNotFoundResult(options);
+ if (result.kind === "response") {
+ applyCdnResponseHeaders(result.response.headers, {
+ cacheControl: ISR_NEVER_CACHE_CONTROL,
+ });
+ return result;
+ }
+ return { ...result, bypassSharedCache: true };
+}
+
export function mergePagesNotFoundSourceHeaders(
response: Response,
sourceHeaders: Record | undefined,
@@ -482,6 +502,16 @@ export function mergePagesNotFoundSourceHeaders(
});
}
+/**
+ * True only when userland overrode App.getInitialProps. The shim's inherited
+ * default (`class MyApp extends App {}`) is request-agnostic and keeps ISR.
+ */
+export function hasCustomAppGetInitialProps(appComponent: unknown): boolean {
+ if (!hasPagesGetInitialProps(appComponent)) return false;
+ const component = appComponent as { getInitialProps?: unknown; origGetInitialProps?: unknown };
+ return component.getInitialProps !== component.origGetInitialProps;
+}
+
function applyPagesTerminalMissHeaders(
response: Response,
revalidateSeconds: number | false,
@@ -1190,6 +1220,10 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr
export async function resolvePagesPageData(
options: ResolvePagesPageDataOptions,
): Promise {
+ // Next.js rejects this combination before it can mix request-derived page
+ // props with a shared static representation.
+ assertPagesDataExportCompatibility(options.pageModule, options.routePattern);
+
// Next.js passes `params: null` (effectively) to gSSP/gSP context for
// non-dynamic routes — see render.tsx's `...(pageIsDynamic ? { params } : undefined)`.
// Internal bookkeeping (route param hydration, ISR HTML, getStaticPaths
@@ -1210,6 +1244,10 @@ export async function resolvePagesPageData(
let shouldPersistFallbackData = false;
let onDemandPreviousCacheEntry: ISRCacheEntry | null | undefined;
const previewData = options.isOnDemandRevalidate ? false : (options.previewData ?? false);
+ // A user-defined App.getInitialProps receives the live request. Its result
+ // can contain cookies, headers, or other per-user data and must never enter
+ // (or be read from) the shared ISR cache used by getStaticProps pages.
+ const hasRequestAwareAppProps = hasCustomAppGetInitialProps(options.AppComponent);
if (typeof options.pageModule.getStaticPaths === "function" && options.route.isDynamic) {
const pathsResult = await options.pageModule.getStaticPaths({
@@ -1234,7 +1272,9 @@ export async function resolvePagesPageData(
// For data requests (`/_next/data/...json`), return a JSON-shaped 404
// so the client router can `res.json()` without blowing up — matches
// Next.js' behavior. HTML navigations still get the configured 404 page.
- return buildPagesNotFoundResult(options);
+ return hasRequestAwareAppProps
+ ? buildRequestAwarePagesNotFoundResult(options)
+ : buildPagesNotFoundResult(options);
}
// Render the fallback shell for unlisted paths under `fallback: true`.
@@ -1260,7 +1300,11 @@ export async function resolvePagesPageData(
options.revalidateOnlyGenerated
) {
const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0];
- onDemandPreviousCacheEntry = await options.isrGet(options.isrCacheKey("pages", pathname));
+ // Request-aware App props never produce a shared entry, so there is
+ // nothing generated to revalidate.
+ onDemandPreviousCacheEntry = hasRequestAwareAppProps
+ ? null
+ : await options.isrGet(options.isrCacheKey("pages", pathname));
if (!onDemandPreviousCacheEntry) {
return {
kind: "response",
@@ -1295,9 +1339,13 @@ export async function resolvePagesPageData(
async function loadForegroundAppInitialRenderProps(): Promise {
const result = await loadPagesAppInitialRenderProps(options, getSharedReqRes);
if (result.kind === "response") {
+ const response = await result.response;
+ if (hasRequestAwareAppProps && typeof options.pageModule.getStaticProps === "function") {
+ applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ }
return {
kind: "response",
- response: await result.response,
+ response,
};
}
renderProps = result.renderProps;
@@ -1307,7 +1355,9 @@ export async function resolvePagesPageData(
if (isFallback) {
const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0];
- const cached = await options.isrGet(options.isrCacheKey("pages", pathname));
+ const cached = hasRequestAwareAppProps
+ ? null
+ : await options.isrGet(options.isrCacheKey("pages", pathname));
if (cached?.value.value?.kind !== "PAGES") {
const appShortCircuit = await loadForegroundAppInitialRenderProps();
if (appShortCircuit) return appShortCircuit;
@@ -1318,6 +1368,7 @@ export async function resolvePagesPageData(
documentReqRes: sharedReqRes,
gsspRes: null,
isrRevalidateSeconds: null,
+ bypassSharedCache: hasRequestAwareAppProps,
pageProps,
props: renderProps,
isFallback: true,
@@ -1405,8 +1456,9 @@ export async function resolvePagesPageData(
if (typeof options.pageModule.getStaticProps === "function") {
const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0];
const cacheKey = options.isrCacheKey("pages", pathname);
- const cached =
- onDemandPreviousCacheEntry !== undefined
+ const cached = hasRequestAwareAppProps
+ ? null
+ : onDemandPreviousCacheEntry !== undefined
? onDemandPreviousCacheEntry
: await options.isrGet(cacheKey);
const cachedValue = cached?.value.value;
@@ -1722,9 +1774,12 @@ export async function resolvePagesPageData(
if (result?.redirect) {
const response = buildPagesRedirectResponse(result.redirect, options, renderProps);
- if (previewData === false) {
- const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl);
- const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds);
+ // Validate `revalidate` even when the result never enters the cache.
+ const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl);
+ const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds);
+ if (hasRequestAwareAppProps) {
+ applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ } else if (previewData === false) {
const redirect = resolvePagesRedirect(result.redirect, {
method: "getStaticProps",
routeUrl: options.routeUrl,
@@ -1749,6 +1804,11 @@ export async function resolvePagesPageData(
if (result?.notFound) {
const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl);
const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds);
+ // The recursive custom 404 render also runs App.getInitialProps, so
+ // request-aware App props must not receive a shared cache lifetime either.
+ if (hasRequestAwareAppProps) {
+ return buildRequestAwarePagesNotFoundResult(options);
+ }
if (previewData === false) {
await options.isrSet(cacheKey, null, {
cacheControl: isrCacheControl(revalidateSeconds, { expireSeconds }),
@@ -1783,10 +1843,13 @@ export async function resolvePagesPageData(
isSerializableProps(options.routePattern, "getStaticProps", pageProps);
}
- if (previewData === false && result) {
+ if (previewData === false && result && !hasRequestAwareAppProps) {
isrRevalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl);
isrExpireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds);
- } else if (previewData === false && options.isOnDemandRevalidate) {
+ } else if (result && hasRequestAwareAppProps) {
+ // Still validate `revalidate`; only cache participation is skipped.
+ resolvePagesRevalidateSeconds(result, options.routeUrl);
+ } else if (previewData === false && options.isOnDemandRevalidate && !hasRequestAwareAppProps) {
// `revalidate: false` (and an omitted `revalidate`) still participates in
// on-demand regeneration. Persist the current invocation's normalized
// lifetime instead of inheriting stale metadata from the previous entry.
@@ -1800,7 +1863,7 @@ export async function resolvePagesPageData(
isrExpireSeconds = cached?.value.cacheControl?.expire;
}
- if (shouldPersistFallbackData && previewData === false) {
+ if (shouldPersistFallbackData && previewData === false && !hasRequestAwareAppProps) {
const revalidateSeconds = isrRevalidateSeconds ?? false;
await options.isrSet(
cacheKey,
@@ -1866,6 +1929,8 @@ export async function resolvePagesPageData(
gsspRes,
isrRevalidateSeconds,
isrExpireSeconds,
+ bypassSharedCache:
+ hasRequestAwareAppProps && typeof options.pageModule.getStaticProps === "function",
pageProps,
props: renderProps,
isFallback: false,
diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts
index e68ebacdb..59b892d07 100644
--- a/packages/vinext/src/server/pages-page-handler.ts
+++ b/packages/vinext/src/server/pages-page-handler.ts
@@ -24,7 +24,15 @@ import {
type PagesPreviewState,
} from "./pages-preview.js";
import { hasUserDocumentGetInitialProps } from "./document-initial-head.js";
-import { mergePagesNotFoundSourceHeaders, resolvePagesPageData } from "./pages-page-data.js";
+import {
+ hasCustomAppGetInitialProps,
+ mergePagesNotFoundSourceHeaders,
+ resolvePagesPageData,
+} from "./pages-page-data.js";
+import {
+ markPagesPrerenderSharedCacheBypass,
+ PagesDataExportCompatibilityError,
+} from "./pages-data-export-compatibility.js";
import type { PagesPageModule } from "./pages-page-data.js";
import { resolvePagesPageMethodResponse } from "./pages-page-method.js";
import { renderPagesPageResponse } from "./pages-page-response.js";
@@ -77,6 +85,7 @@ import {
NEXTJS_CACHE_HEADER,
NEXTJS_DEPLOYMENT_ID_HEADER,
VINEXT_CACHE_HEADER,
+ VINEXT_PRERENDER_RENDER_ERROR_HEADER,
VINEXT_REVALIDATED_CACHE_TAG_HEADER,
} from "./headers.js";
import { buildMissIsrCacheControl, ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js";
@@ -346,6 +355,8 @@ type RenderPageOptions = {
originalUrl?: string;
renderErrorPageOnMiss?: boolean;
__isInternalErrorRender?: boolean;
+ /** Force adapter-backed no-store while recursively rendering an error page. */
+ __bypassSharedCache?: boolean;
__forcedRoute?: PageRoute;
/** Source-page cache lifetime forwarded while rendering a notFound error page. */
__notFoundRevalidateSeconds?: number | false;
@@ -592,6 +603,8 @@ export function createPagesPageHandler(
const { route, params } = match;
const pageModule = route.module;
const isStaticPropsRoute = typeof pageModule.getStaticProps === "function";
+ const requestAwareStaticPropsRoute =
+ isStaticPropsRoute && hasCustomAppGetInitialProps(AppComponent);
const pagesReadiness = buildPagesReadinessNextData({
pageModule,
appComponent: AppComponent as { getInitialProps?: unknown; origGetInitialProps?: unknown },
@@ -980,6 +993,12 @@ export function createPagesPageHandler(
);
}
notFoundResponse = stripPagesNotFoundFramingHeaders(notFoundResponse);
+ if (pageDataResult.bypassSharedCache) {
+ applyCdnResponseHeaders(notFoundResponse.headers, {
+ cacheControl: ISR_NEVER_CACHE_CONTROL,
+ });
+ markPagesPrerenderSharedCacheBypass(notFoundResponse.headers);
+ }
if (isOnDemandRevalidate) {
notFoundResponse = withPagesCacheState(
@@ -1005,6 +1024,10 @@ export function createPagesPageHandler(
errorResponseCachePathname,
);
}
+ if (options?.__bypassSharedCache) {
+ applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ markPagesPrerenderSharedCacheBypass(response.headers);
+ }
return finalizePagesPreviewResponse(response, preview);
}
@@ -1029,6 +1052,8 @@ export function createPagesPageHandler(
// response and must not shorten `/404`'s internal cache lifetime.
const isrRevalidateSeconds = pageDataResult.isrRevalidateSeconds;
const isrExpireSeconds = pageDataResult.isrExpireSeconds;
+ const bypassSharedCache =
+ pageDataResult.bypassSharedCache || options?.__bypassSharedCache === true;
const isFallbackRender = pageDataResult.isFallback === true;
// Republish SSR context with isFallback flipped on so `useRouter().isFallback`
@@ -1068,6 +1093,8 @@ export function createPagesPageHandler(
// skip when gSSP already set one via res.setHeader. Fixes #1461.
if (!headers.has("Cache-Control"))
headers.set("Cache-Control", ISR_NEVER_CACHE_CONTROL);
+ } else if (bypassSharedCache) {
+ applyCdnResponseHeaders(headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
} else if (isStaticPropsRoute) {
if (isrRevalidateSeconds !== null) {
const stem = isrCachePathname.endsWith("/")
@@ -1151,6 +1178,7 @@ export function createPagesPageHandler(
isrCachePathname,
expireSeconds: isrExpireSeconds,
isrRevalidateSeconds,
+ bypassSharedCache,
isOnDemandRevalidate,
isStaticPropsRoute,
isrSet: routeIsrSet,
@@ -1187,9 +1215,17 @@ export function createPagesPageHandler(
errorResponseCachePathname,
);
}
+ if (options?.__bypassSharedCache) {
+ applyCdnResponseHeaders(pageResponse.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ markPagesPrerenderSharedCacheBypass(pageResponse.headers);
+ }
return finalizePagesPreviewResponse(pageResponse, preview);
} catch (e) {
console.error("[vinext] SSR error:", e);
+ const isFatalPrerenderError =
+ e instanceof PagesDataExportCompatibilityError &&
+ typeof process !== "undefined" &&
+ process.env?.VINEXT_PRERENDER === "1";
reportRequestError(
e instanceof Error ? e : new Error(String(e)),
{
@@ -1222,7 +1258,7 @@ export function createPagesPageHandler(
}
if (errorRoute) {
try {
- return await renderPage(
+ const errorResponse = await renderPage(
request,
url,
manifest,
@@ -1232,17 +1268,31 @@ export function createPagesPageHandler(
asPath: url,
renderErrorPageOnMiss: false,
__isInternalErrorRender: true,
+ __bypassSharedCache:
+ requestAwareStaticPropsRoute || options?.__bypassSharedCache === true,
__forcedRoute: errorRoute,
err: e instanceof Error ? e : new Error(String(e)),
},
initialResponseHeaders,
);
+ if (isFatalPrerenderError) {
+ errorResponse.headers.set(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1");
+ }
+ return errorResponse;
} catch (errorPageErr) {
console.error("[vinext] Error page render failed:", errorPageErr);
}
}
}
- return new Response("Internal Server Error", { status: 500 });
+ const response = new Response("Internal Server Error", { status: 500 });
+ if (requestAwareStaticPropsRoute || options?.__bypassSharedCache) {
+ applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ markPagesPrerenderSharedCacheBypass(response.headers);
+ }
+ if (isFatalPrerenderError) {
+ response.headers.set(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1");
+ }
+ return response;
}
});
return closeAfterResponseWithBody(response, uCtx);
diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts
index ec7f530b4..a521974cd 100644
--- a/packages/vinext/src/server/pages-page-response.ts
+++ b/packages/vinext/src/server/pages-page-response.ts
@@ -24,6 +24,7 @@ import {
type RenderPageEnhancers,
runDocumentRenderPage,
} from "./pages-document-initial-props.js";
+import { markPagesPrerenderSharedCacheBypass } from "./pages-data-export-compatibility.js";
import { fnv1a52 } from "../utils/hash.js";
import { readStreamAsText } from "../utils/text-stream.js";
import { callDocumentGetInitialProps } from "./document-initial-head.js";
@@ -159,6 +160,8 @@ type RenderPagesPageResponseOptions = {
isrCachePathname?: string;
expireSeconds?: number;
isrRevalidateSeconds: number | false | null;
+ /** Request-derived App props: emit a private, no-store policy instead of ISR headers. */
+ bypassSharedCache?: boolean;
/** Synchronous `res.revalidate()` render; cache persistence must finish before returning. */
isOnDemandRevalidate?: boolean;
isStaticPropsRoute?: boolean;
@@ -561,7 +564,12 @@ export async function renderPagesPageResponse(
},
});
if (options.documentReqRes?.res.headersSent && options.documentReqRes.responsePromise) {
- return options.documentReqRes.responsePromise;
+ const response = await options.documentReqRes.responsePromise;
+ if (options.bypassSharedCache) {
+ applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ markPagesPrerenderSharedCacheBypass(response.headers);
+ }
+ return response;
}
let bodyStream: ReadableStream;
@@ -697,7 +705,12 @@ export async function renderPagesPageResponse(
// this point, so the captured value matches main's original capture site.
const userSetCacheControl = responseHeaders.has("Cache-Control");
- if (options.scriptNonce) {
+ if (options.bypassSharedCache) {
+ // Checked before the nonce branch: request-derived props need the adapter
+ // so provider-owned edge headers set by App.getInitialProps are cleared.
+ applyCdnResponseHeaders(responseHeaders, { cacheControl: ISR_NEVER_CACHE_CONTROL });
+ markPagesPrerenderSharedCacheBypass(responseHeaders);
+ } else if (options.scriptNonce) {
responseHeaders.set("Cache-Control", ISR_NO_STORE_CACHE_CONTROL);
} else if (options.isrRevalidateSeconds !== null) {
// Fresh ISR (MISS) response: route through the CDN adapter with the path tag
diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts
index 3635e3bde..e889e1c8a 100644
--- a/packages/vinext/src/server/pages-request-pipeline.ts
+++ b/packages/vinext/src/server/pages-request-pipeline.ts
@@ -34,6 +34,12 @@ import { cloneRequestWithUrl, normalizeTrailingSlash } from "./request-pipeline.
import { applyConfigHeadersToHeaderRecord } from "./config-headers.js";
import type { HeaderRecord } from "./request-pipeline.js";
import { mergeHeaders } from "./worker-utils.js";
+import {
+ applyCdnResponseHeaders,
+ hasExplicitNonCacheableResponsePolicy,
+ isNonCacheableCacheControl,
+ NEVER_CACHE_CONTROL,
+} from "./cache-control.js";
import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./pages-i18n.js";
import { mergeRewriteQuery } from "../utils/query.js";
import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js";
@@ -943,7 +949,22 @@ export async function runPagesRequest(
renderPageMatch?.route.pattern,
);
}
+ const responseIsExplicitlyNonCacheable = hasExplicitNonCacheableResponsePolicy(
+ response.headers,
+ );
+ const responseCacheControl = response.headers.get("cache-control");
const merged = mergeHeaders(response, matchedPathHeaders, middlewareStatus);
+ if (responseIsExplicitlyNonCacheable) {
+ // A rendered non-cacheable policy is final. Reapply it after staged
+ // headers merge so adapter-owned cache metadata cannot reappear from
+ // middleware or next.config headers.
+ applyCdnResponseHeaders(merged.headers, {
+ cacheControl:
+ responseCacheControl && isNonCacheableCacheControl(responseCacheControl)
+ ? responseCacheControl
+ : NEVER_CACHE_CONTROL,
+ });
+ }
// Preserve the streaming marker so the adapter can decide stream-vs-buffer.
// mergeHeaders may create a new Response object (losing non-standard properties),
// so we copy the marker from the original render response to the merged one.
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index e3f486f6c..68b6c6bf8 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -67,6 +67,10 @@ import {
} from "../utils/asset-prefix.js";
import { computeClientRuntimeMetadata } from "../utils/client-runtime-metadata.js";
import { setPagesClientAssets } from "./pages-client-assets.js";
+import {
+ assertPagesDataExportCompatibility,
+ type PagesDataExportModule,
+} from "./pages-data-export-compatibility.js";
import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";
import { isUnknownRecord } from "../utils/record.js";
import type { ExecutionContextLike } from "vinext/shims/request-context";
@@ -1884,7 +1888,7 @@ type PagesRouterServerOptions = {
type PagesServerEntryPageRoute = {
pattern: string;
- module?: {
+ module?: PagesDataExportModule & {
getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => Promise;
};
};
@@ -2039,13 +2043,15 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
const locales: string[] = localesRaw ? JSON.parse(localesRaw) : [];
const defaultLocale = parsedUrl.searchParams.get("defaultLocale") ?? "";
const route = pageRoutes?.find((r) => r.pattern === pattern);
- const fn = route?.module?.getStaticPaths;
- if (typeof fn !== "function") {
- res.writeHead(204);
- res.end();
- return;
- }
+ const pageModule = route?.module;
+ const fn = pageModule?.getStaticPaths;
try {
+ assertPagesDataExportCompatibility(pageModule ?? {}, pattern);
+ if (typeof fn !== "function") {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
const result = await fn({ locales, defaultLocale });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
diff --git a/tests/app-prerender-endpoints.test.ts b/tests/app-prerender-endpoints.test.ts
index 91170fca5..3821509ed 100644
--- a/tests/app-prerender-endpoints.test.ts
+++ b/tests/app-prerender-endpoints.test.ts
@@ -6,6 +6,8 @@ import { getRootParam } from "../packages/vinext/src/shims/root-params.js";
type TestPageRoute = {
pattern: string;
module?: {
+ default?: unknown;
+ getStaticProps?: () => unknown;
getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => unknown;
};
};
@@ -231,6 +233,35 @@ describe("App prerender endpoint helpers", () => {
});
});
+ it("rejects incompatible page exports before empty static paths can skip rendering", async () => {
+ const Page = Object.assign(() => null, { getInitialProps: async () => ({}) });
+ const getStaticPaths = vi.fn(() => ({ fallback: "blocking", paths: [] }));
+ const response = await handleAppPrerenderEndpoint(
+ new Request("http://localhost/__vinext/prerender/pages-static-paths?pattern=/posts/:id"),
+ {
+ isPrerenderEnabled: () => true,
+ loadPagesRoutes: async () => [
+ {
+ module: {
+ default: Page,
+ getStaticPaths,
+ getStaticProps: () => ({ props: {} }),
+ },
+ pattern: "/posts/:id",
+ },
+ ],
+ pathname: "/__vinext/prerender/pages-static-paths",
+ staticParamsMap: {},
+ },
+ );
+
+ expect(getStaticPaths).not.toHaveBeenCalled();
+ expect(response?.status).toBe(500);
+ await expect(response?.json()).resolves.toMatchObject({
+ error: expect.stringContaining("You can not use getInitialProps with getStaticProps"),
+ });
+ });
+
it("returns no content when the requested prerender function is absent", async () => {
const staticParamsResponse = await handleAppPrerenderEndpoint(
new Request("http://localhost/__vinext/prerender/static-params?pattern=/missing"),
diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts
index 826b3ff18..9906359fd 100644
--- a/tests/cloudflare-cdn-cache.test.ts
+++ b/tests/cloudflare-cdn-cache.test.ts
@@ -375,6 +375,7 @@ describe("CloudflareCdnCacheAdapter", () => {
// a CDN-Cache-Control directive (which would cache an uncacheable response).
for (const cc of [
"no-store, must-revalidate",
+ "NO-STORE",
"private, no-cache, no-store, max-age=0, must-revalidate",
]) {
const headers = adapter.buildResponseHeaders({ cacheControl: cc, tags: ["x"] });
diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts
index 04fd015d9..fbff11339 100644
--- a/tests/pages-page-data.test.ts
+++ b/tests/pages-page-data.test.ts
@@ -326,7 +326,114 @@ describe("pages page data", () => {
});
});
- it("preserves custom app props during stale ISR regeneration", async () => {
+ it("does not cache request-derived _app props for getStaticProps pages", async () => {
+ const isrGet = vi.fn().mockResolvedValue({
+ isStale: false,
+ value: {
+ cacheControl: { revalidate: 60 },
+ value: {
+ kind: "PAGES",
+ html: "cached private response",
+ pageData: { appSecret: "sid=alice-private", pageProps: { cached: true } },
+ },
+ },
+ });
+ const isrSet = vi.fn(async () => {});
+ const appGetInitialProps = vi.fn(
+ ({ ctx }: { ctx: { req: { headers: { cookie: string } } } }) => ({
+ appSecret: ctx.req.headers.cookie,
+ pageProps: {},
+ }),
+ );
+
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(function App() {}, {
+ getInitialProps: appGetInitialProps,
+ }),
+ createGsspReqRes() {
+ return {
+ req: { headers: { cookie: "sid=bob-private" } },
+ res: {
+ headersSent: false,
+ statusCode: 200,
+ getHeaders() {
+ return {};
+ },
+ },
+ responsePromise: Promise.resolve(new Response()),
+ };
+ },
+ isrGet,
+ isrSet,
+ pageModule: {
+ default: function Page() {},
+ getStaticProps() {
+ return { props: { fresh: true }, revalidate: 60 };
+ },
+ },
+ }),
+ );
+
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
+ appSecret: "sid=bob-private",
+ pageProps: { fresh: true },
+ },
+ });
+ expect(appGetInitialProps).toHaveBeenCalledOnce();
+ expect(isrGet).not.toHaveBeenCalled();
+ expect(isrSet).not.toHaveBeenCalled();
+ });
+
+ it("does not cache request-derived _app props during on-demand revalidation", async () => {
+ const isrGet = vi.fn().mockResolvedValue({
+ isStale: true,
+ value: {
+ cacheControl: { revalidate: 60 },
+ value: {
+ kind: "PAGES",
+ html: "cached private response",
+ pageData: { appSecret: "sid=alice-private", pageProps: { cached: true } },
+ },
+ },
+ });
+ const isrSet = vi.fn(async () => {});
+
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(function App() {}, {
+ getInitialProps() {
+ return { appSecret: "sid=bob-private", pageProps: {} };
+ },
+ }),
+ isOnDemandRevalidate: true,
+ isrGet,
+ isrSet,
+ pageModule: {
+ default: function Page() {},
+ getStaticProps() {
+ return { props: { fresh: true }, revalidate: 60 };
+ },
+ },
+ }),
+ );
+
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
+ appSecret: "sid=bob-private",
+ pageProps: { fresh: true },
+ },
+ });
+ expect(isrGet).not.toHaveBeenCalled();
+ expect(isrSet).not.toHaveBeenCalled();
+ });
+
+ it("bypasses stale ISR entries when a custom app supplies request-aware props", async () => {
const isrSet = vi.fn(async () => {});
const createPageElement = vi.fn(() => "page");
let requestContextsApplied = false;
@@ -362,7 +469,7 @@ describe("pages page data", () => {
pageModule: {
default: function Page() {},
getStaticProps() {
- expect(requestContextsApplied).toBe(true);
+ expect(requestContextsApplied).toBe(false);
return { props: { fromStatic: true }, revalidate: 10 };
},
},
@@ -372,22 +479,18 @@ describe("pages page data", () => {
}),
);
- expect(result.kind).toBe("response");
- await regenerationPromise;
- expect(createPageElement).toHaveBeenCalledWith({
- appValue: "fresh-app",
- pageProps: { fromApp: true, fromStatic: true },
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
+ 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 },
- },
- }),
- { cacheControl: { revalidate: 10, expire: 300 } },
- );
+ expect(appGetInitialPropsCalls).toBe(1);
+ expect(regenerationPromise).toBeUndefined();
+ expect(createPageElement).not.toHaveBeenCalled();
+ expect(isrSet).not.toHaveBeenCalled();
});
it("returns a notFound signal when getStaticPaths excludes a dynamic HTML path", async () => {
@@ -1393,7 +1496,7 @@ describe("pages page data", () => {
);
});
- it("preserves _app.getInitialProps app-level props during stale ISR regeneration", async () => {
+ it("does not regenerate stale ISR entries with _app.getInitialProps props", async () => {
let regenPromise: Promise | null = null;
const isrSet = vi.fn(async () => {});
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => {
@@ -1448,43 +1551,21 @@ describe("pages page data", () => {
}),
);
- 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 = regenPromise;
- await pendingRegen;
-
- expect(capturedRenderProps).toEqual(
- expect.objectContaining({
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
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" },
- },
- }),
- );
- if (regeneratedCacheValue?.kind !== "PAGES") throw new Error("expected PAGES cache value");
- expect(regeneratedCacheValue?.html).toContain('"appProp":"from-app"');
- expect(regeneratedCacheValue?.html).toContain('"pageProp":"from-page"');
- expect(regeneratedCacheValue?.html).toContain('"page":"/posts/[slug]"');
+ },
+ });
+ expect(regenPromise).toBeNull();
+ expect(capturedRenderProps).toBeUndefined();
+ expect(triggerBackgroundRegeneration).not.toHaveBeenCalled();
+ expect(isrSet).not.toHaveBeenCalled();
});
- it("does not run _app.getInitialProps on a fresh ISR cache HIT", async () => {
+ it("bypasses fresh ISR cache HITs when _app.getInitialProps is present", async () => {
const appGip = vi.fn().mockResolvedValue({
appProp: "from-app",
pageProps: {},
@@ -1521,15 +1602,199 @@ describe("pages page data", () => {
}),
);
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
+ appProp: "from-app",
+ pageProps: { pageProp: "fresh" },
+ },
+ });
+ expect(appGip).toHaveBeenCalledOnce();
+ });
+
+ it("keeps ISR for an App that inherits the shim's default getInitialProps", async () => {
+ const isrGet = vi.fn().mockResolvedValue(null);
+ const defaultGip = vi.fn().mockResolvedValue({ pageProps: {} });
+ const result = await resolvePagesPageData(
+ createOptions({
+ // Mirrors `class MyApp extends App {}`: the static getInitialProps is
+ // the shim default, not a userland override.
+ AppComponent: Object.assign(
+ function App() {
+ return null;
+ },
+ { getInitialProps: defaultGip, origGetInitialProps: defaultGip },
+ ),
+ isrGet,
+ pageModule: {
+ async getStaticProps() {
+ return { props: { pageProp: "fresh" }, revalidate: 60 };
+ },
+ },
+ }),
+ );
+
+ expect(result).toMatchObject({ kind: "render", isrRevalidateSeconds: 60 });
+ expect(isrGet).toHaveBeenCalledWith("pages:/posts/post");
+ });
+
+ it("rejects page getInitialProps combined with getStaticProps", async () => {
+ const Page = Object.assign(
+ function Page() {
+ return null;
+ },
+ { getInitialProps: vi.fn().mockResolvedValue({ viewer: "me" }) },
+ );
+
+ await expect(
+ resolvePagesPageData(
+ createOptions({
+ pageModule: {
+ default: Page,
+ getStaticProps: vi.fn().mockResolvedValue({ props: {} }),
+ },
+ }),
+ ),
+ ).rejects.toThrow(
+ "You can not use getInitialProps with getStaticProps. To use SSG, please remove your getInitialProps /posts/[slug]",
+ );
+ });
+
+ it("skips the shared cache probe for request-aware fallback shells", async () => {
+ const isrGet = vi.fn().mockResolvedValue({
+ isStale: false,
+ value: {
+ cacheControl: { revalidate: 60 },
+ value: {
+ kind: "PAGES",
+ html: "legacy cached page",
+ pageData: { pageProps: { cached: true } },
+ },
+ },
+ });
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(
+ function App() {
+ return null;
+ },
+ { getInitialProps: vi.fn().mockResolvedValue({ viewer: "me", pageProps: {} }) },
+ ),
+ pageModule: {
+ getStaticPaths() {
+ return { paths: [], fallback: true };
+ },
+ async getStaticProps() {
+ return { props: { pageProp: "fresh" }, revalidate: 60 };
+ },
+ },
+ isrGet,
+ route: { isDynamic: true },
+ routePattern: "/posts/[slug]",
+ routeUrl: "/posts/unseen",
+ }),
+ );
+
+ expect(result).toMatchObject({
+ kind: "render",
+ isFallback: true,
+ bypassSharedCache: true,
+ props: { viewer: "me" },
+ });
+ expect(isrGet).not.toHaveBeenCalled();
+ });
+
+ it("returns an uncacheable notFound when a custom App supplies request-aware props", async () => {
+ const isrSet = vi.fn(async () => {});
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(
+ function App() {
+ return null;
+ },
+ { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) },
+ ),
+ isrSet,
+ pageModule: {
+ async getStaticProps() {
+ return { notFound: true, revalidate: 60 };
+ },
+ },
+ }),
+ );
+
+ expect(result).toEqual({
+ kind: "notFound",
+ bypassSharedCache: true,
+ revalidateSeconds: undefined,
+ expireSeconds: undefined,
+ cacheState: undefined,
+ responseHeaders: undefined,
+ });
+ expect(isrSet).not.toHaveBeenCalled();
+ });
+
+ it("marks request-aware fallback false path misses as bypassing shared cache", async () => {
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(
+ function App() {
+ return null;
+ },
+ { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) },
+ ),
+ pageModule: {
+ getStaticPaths() {
+ return { paths: [], fallback: false };
+ },
+ async getStaticProps() {
+ return { props: {}, revalidate: 60 };
+ },
+ },
+ route: { isDynamic: true },
+ routePattern: "/posts/[slug]",
+ routeUrl: "/posts/unlisted",
+ }),
+ );
+
+ expect(result).toEqual({
+ kind: "notFound",
+ bypassSharedCache: true,
+ revalidateSeconds: undefined,
+ expireSeconds: undefined,
+ cacheState: undefined,
+ responseHeaders: undefined,
+ });
+ });
+
+ it("treats only-generated revalidation as a no-op when a custom App supplies request-aware props", async () => {
+ const isrGet = vi.fn().mockResolvedValue(null);
+ const getStaticProps = vi.fn(async () => ({ props: { pageProp: "fresh" }, revalidate: 60 }));
+ const result = await resolvePagesPageData(
+ createOptions({
+ AppComponent: Object.assign(
+ function App() {
+ return null;
+ },
+ { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) },
+ ),
+ isOnDemandRevalidate: true,
+ revalidateOnlyGenerated: true,
+ isrGet,
+ pageModule: { getStaticProps },
+ }),
+ );
+
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();
+ if (result.kind !== "response") throw new Error("expected response result");
+ expect(result.response.status).toBe(404);
+ expect(result.response.headers.get("x-nextjs-cache")).toBe("REVALIDATED");
+ expect(isrGet).not.toHaveBeenCalled();
+ expect(getStaticProps).not.toHaveBeenCalled();
});
- it("only runs _app.getInitialProps in the stale ISR regeneration path, not on the immediate stale response", async () => {
+ it("renders request-aware app props in the foreground instead of serving stale ISR HTML", async () => {
let regenPromise: Promise | null = null;
const isrSet = vi.fn(async () => {});
const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => {
@@ -1588,24 +1853,20 @@ describe("pages page data", () => {
}),
);
- 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 = regenPromise;
- await pendingRegen;
-
- // App GIP must run exactly once, inside the background regeneration callback.
- expect(regenGipCalls).toBe(1);
+ expect(result).toMatchObject({
+ kind: "render",
+ isrRevalidateSeconds: null,
+ props: {
+ appProp: "from-app",
+ pageProps: { pageProp: "from-page" },
+ },
+ });
+ expect(foregroundGipCalls).toBe(1);
+ expect(regenGipCalls).toBe(0);
+ expect(regenPromise).toBeNull();
expect(appGip).toHaveBeenCalledOnce();
- expect(isrSet).toHaveBeenCalledOnce();
+ expect(triggerBackgroundRegeneration).not.toHaveBeenCalled();
+ expect(isrSet).not.toHaveBeenCalled();
});
it("preserves vinext module metadata during stale ISR regeneration", async () => {
@@ -1734,6 +1995,7 @@ describe("pages page data", () => {
gsspRes: null,
isrExpireSeconds: 300,
isrRevalidateSeconds: 30,
+ bypassSharedCache: false,
pageProps: { title: "hello" },
props: { pageProps: { title: "hello" } },
isFallback: false,
diff --git a/tests/pages-page-handler.test.ts b/tests/pages-page-handler.test.ts
index 7dfce366e..40553d3c3 100644
--- a/tests/pages-page-handler.test.ts
+++ b/tests/pages-page-handler.test.ts
@@ -27,7 +27,11 @@ import {
PRERENDER_REVALIDATE_HEADER,
} from "../packages/vinext/src/server/isr-cache.js";
import { after } from "../packages/vinext/src/shims/server.js";
-import { VINEXT_REVALIDATED_CACHE_TAG_HEADER } from "../packages/vinext/src/server/headers.js";
+import {
+ VINEXT_PRERENDER_RENDER_ERROR_HEADER,
+ VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER,
+ VINEXT_REVALIDATED_CACHE_TAG_HEADER,
+} from "../packages/vinext/src/server/headers.js";
afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter()));
@@ -437,6 +441,297 @@ describe("createPagesPageHandler — no default export", () => {
// _next/data JSON envelope
// ---------------------------------------------------------------------------
+describe("createPagesPageHandler — request-aware App props", () => {
+ const AppComponent = Object.assign(() => null, {
+ getInitialProps: async () => ({ viewer: "per-request", pageProps: {} }),
+ });
+ function makeHandler(pageModule: Record) {
+ return createPagesPageHandler(
+ makeOpts({
+ AppComponent,
+ pageRoutes: [makeRoute("/about", pageModule)],
+ matchRoute: (url, r) => {
+ const route = r.find((rt) => rt.pattern === url.split("?")[0]);
+ return route ? { route, params: {} } : null;
+ },
+ }),
+ );
+ }
+
+ it("marks getStaticProps HTML and data responses as private no-store", async () => {
+ const handler = makeHandler(
+ makePageModule({ getStaticProps: async () => ({ props: { a: 1 }, revalidate: 60 }) }),
+ );
+
+ const html = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(html.status).toBe(200);
+ expect(html.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ expect(html.headers.get("x-nextjs-cache")).toBeNull();
+ expect(html.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER)).toBeNull();
+
+ const dataUrl = "/_next/data/test-build-id/about.json";
+ const data = await handler(makeRequest(dataUrl), dataUrl, null, null, null);
+ expect(data.status).toBe(200);
+ expect(data.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ expect(data.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER)).toBeNull();
+ });
+
+ it("clears adapter-owned edge cache headers set by App.getInitialProps", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const EdgeApp = Object.assign(() => null, {
+ getInitialProps: async ({
+ ctx,
+ }: {
+ ctx: { res: { setHeader(n: string, v: string): void } };
+ }) => {
+ ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ return { viewer: "per-request", pageProps: {} };
+ },
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ AppComponent: EdgeApp,
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }),
+ ),
+ ],
+ matchRoute: (url, r) => {
+ const route = r.find((rt) => rt.pattern === url.split("?")[0]);
+ return route ? { route, params: {} } : null;
+ },
+ }),
+ );
+
+ const html = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(html.status).toBe(200);
+ expect(html.headers.get("cdn-cache-control")).toBeNull();
+ expect(html.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ });
+
+ it("clears edge cache headers even for nonce-bearing renders", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const EdgeApp = Object.assign(() => null, {
+ getInitialProps: async ({
+ ctx,
+ }: {
+ ctx: { res: { setHeader(n: string, v: string): void } };
+ }) => {
+ ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ return { viewer: "per-request", pageProps: {} };
+ },
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ AppComponent: EdgeApp,
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }),
+ ),
+ ],
+ matchRoute: (url, r) => {
+ const route = r.find((rt) => rt.pattern === url.split("?")[0]);
+ return route ? { route, params: {} } : null;
+ },
+ }),
+ );
+
+ const request = new Request("http://localhost/about", {
+ headers: { "content-security-policy": "script-src 'nonce-abc123'" },
+ });
+ const html = await handler(request, "/about", null, null, null);
+ expect(html.status).toBe(200);
+ expect(html.headers.get("cdn-cache-control")).toBeNull();
+ expect(html.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ });
+
+ it("clears edge cache headers when App.getInitialProps ends the response", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const EdgeApp = Object.assign(() => null, {
+ getInitialProps: async ({
+ ctx,
+ }: {
+ ctx: {
+ res: {
+ end(body: string): void;
+ setHeader(name: string, value: string): void;
+ };
+ };
+ }) => {
+ ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ ctx.res.end("request-aware short circuit");
+ return { viewer: "per-request", pageProps: {} };
+ },
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ AppComponent: EdgeApp,
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }),
+ ),
+ ],
+ matchRoute: (url, routes) => {
+ const route = routes.find((candidate) => candidate.pattern === url.split("?")[0]);
+ return route ? { route, params: {} } : null;
+ },
+ }),
+ );
+
+ const response = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("request-aware short circuit");
+ expect(response.headers.get("cdn-cache-control")).toBeNull();
+ expect(response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ });
+
+ it("clears edge cache headers when Document.getInitialProps ends the response", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const EdgeDocument = Object.assign(() => null, {
+ getInitialProps: async ({
+ res,
+ }: {
+ res: {
+ end(body: string): void;
+ setHeader(name: string, value: string): void;
+ };
+ }) => {
+ res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ res.end("request-aware document short circuit");
+ return { html: "ignored after res.end" };
+ },
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ AppComponent,
+ DocumentComponent: EdgeDocument,
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }),
+ ),
+ ],
+ }),
+ );
+
+ const response = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("request-aware document short circuit");
+ expect(response.headers.get("cdn-cache-control")).toBeNull();
+ expect(response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ });
+
+ it("still rejects an invalid revalidate value", async () => {
+ const handler = makeHandler(
+ makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 0 }) }),
+ );
+ const res = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(res.status).toBe(500);
+ });
+
+ it("carries request-aware cache bypass into a recursive 500 render", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const EdgeApp = Object.assign(() => null, {
+ getInitialProps: async ({
+ ctx,
+ }: {
+ ctx: { res: { setHeader(name: string, value: string): void } };
+ }) => {
+ ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ return { viewer: "per-request", pageProps: {} };
+ },
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ AppComponent: EdgeApp,
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({
+ getStaticProps: async () => {
+ throw new Error("request-aware data failed");
+ },
+ }),
+ ),
+ makeRoute("/500"),
+ ],
+ }),
+ );
+
+ for (const contentSecurityPolicy of [undefined, "script-src 'nonce-test-nonce'"]) {
+ const response = await handler(
+ new Request("http://localhost/about", {
+ headers: contentSecurityPolicy
+ ? { "content-security-policy": contentSecurityPolicy }
+ : undefined,
+ }),
+ "/about",
+ null,
+ null,
+ null,
+ );
+
+ expect(response.status).toBe(500);
+ expect(response.headers.get("cdn-cache-control")).toBeNull();
+ expect(response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ }
+ });
+});
+
+describe("createPagesPageHandler — data export compatibility", () => {
+ it("marks the invalid getInitialProps and getStaticProps combination fatal only during prerender", async () => {
+ const Page = Object.assign(() => null, {
+ getInitialProps: async () => ({ pageProps: {} }),
+ });
+ const handler = createPagesPageHandler(
+ makeOpts({
+ pageRoutes: [
+ makeRoute(
+ "/about",
+ makePageModule({
+ default: Page,
+ getStaticProps: async () => ({ props: {} }),
+ }),
+ ),
+ ],
+ }),
+ );
+ const savedPrerender = process.env.VINEXT_PRERENDER;
+
+ try {
+ delete process.env.VINEXT_PRERENDER;
+ const runtimeResponse = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(runtimeResponse.status).toBe(500);
+ expect(runtimeResponse.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER)).toBeNull();
+
+ process.env.VINEXT_PRERENDER = "1";
+ const prerenderResponse = await handler(makeRequest("/about"), "/about", null, null, null);
+ expect(prerenderResponse.status).toBe(500);
+ expect(prerenderResponse.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER)).toBe("1");
+ } finally {
+ if (savedPrerender === undefined) delete process.env.VINEXT_PRERENDER;
+ else process.env.VINEXT_PRERENDER = savedPrerender;
+ }
+ });
+});
+
describe("createPagesPageHandler — _next/data", () => {
it("detects /_next/data URL and returns JSON envelope", async () => {
const routes = [makeRoute("/about")];
@@ -918,6 +1213,39 @@ describe("createPagesPageHandler — preview responses", () => {
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
expect(response.headers.get("cache-control")).not.toContain("s-maxage");
});
+
+ it("propagates request-aware cache bypass through a nonce-bearing custom 404", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const AppComponent = Object.assign(() => null, {
+ getInitialProps: async ({
+ ctx,
+ }: {
+ ctx: { res: { setHeader(name: string, value: string): void } };
+ }) => {
+ ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600");
+ return { viewer: "per-request", pageProps: {} };
+ },
+ });
+ const sourceRoute = makeRoute(
+ "/missing",
+ makePageModule({ getStaticProps: async () => ({ notFound: true, revalidate: 7 }) }),
+ );
+ const notFoundRoute = makeRoute("/404");
+ const handler = createPagesPageHandler(
+ makeOpts({ AppComponent, pageRoutes: [sourceRoute, notFoundRoute] }),
+ );
+ const request = new Request("http://localhost/missing", {
+ headers: { "content-security-policy": "script-src 'nonce-test-nonce'" },
+ });
+
+ const response = await handler(request, "/missing", null, null, null);
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("cdn-cache-control")).toBeNull();
+ expect(response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ });
});
// ---------------------------------------------------------------------------
diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts
index d2d289fbc..3aadcdfa8 100644
--- a/tests/pages-request-pipeline.test.ts
+++ b/tests/pages-request-pipeline.test.ts
@@ -8,6 +8,11 @@ import {
} from "../packages/vinext/src/server/pages-request-pipeline.js";
import { MIDDLEWARE_SKIP_HEADER } from "../packages/vinext/src/server/headers.js";
import { PRERENDER_REVALIDATE_HEADER } from "../packages/vinext/src/utils/protocol-headers.js";
+import {
+ DefaultCdnCacheAdapter,
+ setCdnCacheAdapter,
+} from "../packages/vinext/src/shims/cdn-cache.js";
+import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
import {
CACHEABILITY_REQUEST_STATE,
@@ -50,6 +55,103 @@ function makeRenderPage(status = 200, body = "ok") {
);
}
+describe("final cache header merge", () => {
+ it("keeps a rendered no-store policy authoritative over staged CDN headers", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ try {
+ const result = await runPagesRequest(
+ makeRequest("/private"),
+ baseDeps({
+ hasMiddleware: true,
+ runMiddleware: makeMiddleware({
+ responseHeaders: [
+ ["CDN-Cache-Control", "s-maxage=600"],
+ ["Cloudflare-CDN-Cache-Control", "s-maxage=600"],
+ ["Cache-Tag", "private-page"],
+ ],
+ }),
+ renderPage: vi.fn(
+ async () =>
+ new Response("private", {
+ headers: {
+ "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate",
+ },
+ }),
+ ),
+ }),
+ );
+
+ expect(result.type).toBe("response");
+ if (result.type !== "response") return;
+ expect(result.response.headers.get("cdn-cache-control")).toBeNull();
+ expect(result.response.headers.get("cloudflare-cdn-cache-control")).toBeNull();
+ expect(result.response.headers.get("cache-tag")).toBeNull();
+ expect(result.response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ } finally {
+ setCdnCacheAdapter(new DefaultCdnCacheAdapter());
+ }
+ });
+
+ it("does not promote a provider no-store policy from a cacheable browser header", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ try {
+ const result = await runPagesRequest(
+ makeRequest("/private"),
+ baseDeps({
+ renderPage: vi.fn(
+ async () =>
+ new Response("private", {
+ headers: {
+ "Cache-Control": "public, max-age=3600",
+ "CDN-Cache-Control": "no-store",
+ },
+ }),
+ ),
+ }),
+ );
+
+ expect(result.type).toBe("response");
+ if (result.type !== "response") return;
+ expect(result.response.headers.get("cdn-cache-control")).toBeNull();
+ expect(result.response.headers.get("cache-control")).toBe(
+ "private, no-cache, no-store, max-age=0, must-revalidate",
+ );
+ } finally {
+ setCdnCacheAdapter(new DefaultCdnCacheAdapter());
+ }
+ });
+
+ it("preserves a rendered private browser policy while clearing staged CDN headers", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ try {
+ const result = await runPagesRequest(
+ makeRequest("/private"),
+ baseDeps({
+ hasMiddleware: true,
+ runMiddleware: makeMiddleware({
+ responseHeaders: [["CDN-Cache-Control", "s-maxage=600"]],
+ }),
+ renderPage: vi.fn(
+ async () =>
+ new Response("private", {
+ headers: { "Cache-Control": "private, max-age=300" },
+ }),
+ ),
+ }),
+ );
+
+ expect(result.type).toBe("response");
+ if (result.type !== "response") return;
+ expect(result.response.headers.get("cdn-cache-control")).toBeNull();
+ expect(result.response.headers.get("cache-control")).toBe("private, max-age=300");
+ } finally {
+ setCdnCacheAdapter(new DefaultCdnCacheAdapter());
+ }
+ });
+});
+
async function cacheabilityReasonFor(
request: Request,
overrides: Partial,
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index 8ea7c6aad..32292b2bd 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -2776,6 +2776,37 @@ export default class CustomDocument extends Document {
expect(nextData.props).toEqual({ pageProps: {} });
});
+ it.each([true, false])(
+ "rejects getInitialProps with getStaticProps before a dev fallback: %s exit",
+ async (fallback) => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-invalid-fallback-exports-"));
+ fs.mkdirSync(path.join(tmpDir, "pages", "products"), { recursive: true });
+ fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules"));
+ fs.writeFileSync(path.join(tmpDir, "pages", "_app.tsx"), PAGES_APP_COMPONENT);
+ fs.writeFileSync(
+ path.join(tmpDir, "pages", "products", "[id].tsx"),
+ `export default function Product() { return fallback product
; }
+Product.getInitialProps = async () => ({ fromInitialProps: true });
+export function getStaticPaths() { return { paths: [], fallback: ${fallback} }; }
+export function getStaticProps() { return { props: { fromStaticProps: true } }; }
+`,
+ );
+
+ let tempServer: ViteDevServer | undefined;
+ try {
+ const started = await startFixtureServer(tmpDir);
+ tempServer = started.server;
+
+ const response = await fetch(`${started.baseUrl}/products/unlisted`);
+ expect(response.status).toBe(500);
+ expect(await response.text()).not.toContain("fallback product");
+ } finally {
+ await tempServer?.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ },
+ );
+
it("resolves real props for the data URL of an unlisted fallback: true path", async () => {
// Counterpart to the fallback-shell test: the page HTML ships empty props,
// but the client follows up with `/_next/data//products/unknown.json`
@@ -9870,34 +9901,35 @@ describe("custom App optional pageProps envelope parity", () => {
});
}
- // Next.js renders App with `{ ...props, router }`. Production verifies the
- // ISR MISS -> HIT transition; development reruns GSP for each request.
+ // Next.js renders App with `{ ...props, router }`. A custom App can derive
+ // props from the live request, so production must bypass shared ISR while
+ // both modes preserve the merged props across repeated GSP renders.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx
// https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts
for (const mode of ["dev", "prod"] as const) {
it(`merges primitive App props and preserves router across repeated GSP renders in ${mode}`, async () => {
const baseUrl = mode === "dev" ? devUrl : prodUrl;
- const missResponse = await fetch(`${baseUrl}/gsp-string`);
- expect(missResponse.status).toBe(200);
- expect(missResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : "MISS");
+ const firstResponse = await fetch(`${baseUrl}/gsp-string`);
+ expect(firstResponse.status).toBe(200);
+ expect(firstResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : null);
if (mode === "dev") {
- expect(missResponse.headers.get("cache-control")).toBe("no-cache, must-revalidate");
+ expect(firstResponse.headers.get("cache-control")).toBe("no-cache, must-revalidate");
}
- const missHtml = await missResponse.text();
- expect(missHtml).toContain(
+ const firstHtml = await firstResponse.text();
+ expect(firstHtml).toContain(
'{"0":"h","1":"i","fromData":"gsp"}
',
);
- expect(missHtml).toContain('/gsp-string
');
+ expect(firstHtml).toContain('/gsp-string
');
- const hitResponse = await fetch(`${baseUrl}/gsp-string`);
- expect(hitResponse.status).toBe(200);
- expect(hitResponse.headers.get("x-nextjs-cache")).toBe("HIT");
- const hitHtml = await hitResponse.text();
- expect(hitHtml).toContain(
+ const secondResponse = await fetch(`${baseUrl}/gsp-string`);
+ expect(secondResponse.status).toBe(200);
+ expect(secondResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : null);
+ const secondHtml = await secondResponse.text();
+ expect(secondHtml).toContain(
'{"0":"h","1":"i","fromData":"gsp"}
',
);
- expect(hitHtml).toContain('/gsp-string
');
+ expect(secondHtml).toContain('/gsp-string
');
});
}
});
diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts
index 60053a96a..9a55005e2 100644
--- a/tests/prerender.test.ts
+++ b/tests/prerender.test.ts
@@ -21,7 +21,11 @@ import {
type PrerenderRouteResult,
type StaticParamsMap,
} from "../packages/vinext/src/build/prerender.js";
-import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "../packages/vinext/src/server/headers.js";
+import {
+ VINEXT_PRERENDER_RENDER_ERROR_HEADER,
+ VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER,
+ VINEXT_PRERENDER_SPECULATIVE_HEADER,
+} from "../packages/vinext/src/server/headers.js";
import { safeJsonStringify } from "../packages/vinext/src/server/html.js";
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
import {
@@ -776,6 +780,115 @@ describe("prerenderApp — RSC extraction", () => {
// ─── Pages Router ─────────────────────────────────────────────────────────────
+describe("prerenderPages — response cache policy", () => {
+ it.each(["default", "export"] as const)(
+ "handles non-cacheable Pages responses safely in %s mode",
+ async (mode) => {
+ const root = tmpDir("vinext-prerender-pages-no-store-");
+ const pagesDir = path.join(root, "pages");
+ const outDir = path.join(root, "out");
+ fs.mkdirSync(pagesDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(pagesDir, "index.tsx"),
+ `export default function Page() { return null; }
+export function getStaticProps() { return { props: {}, revalidate: 60 }; }
+`,
+ );
+ fs.writeFileSync(
+ path.join(pagesDir, "404.tsx"),
+ `export default function NotFound() { return null; }
+`,
+ );
+ fs.writeFileSync(
+ path.join(pagesDir, "failure.tsx"),
+ `export default function Failure() { return null; }
+`,
+ );
+ fs.writeFileSync(
+ path.join(pagesDir, "fatal.tsx"),
+ `export default function Fatal() { return null; }
+`,
+ );
+ fs.writeFileSync(
+ path.join(pagesDir, "cacheable.tsx"),
+ `export default function Cacheable() { return null; }
+`,
+ );
+
+ const server = createServer((req, res) => {
+ res.setHeader("content-type", "text/html");
+ res.setHeader("cache-control", "private, no-cache, no-store, max-age=0");
+ if (req.url === "/" || req.url === "/404") {
+ res.setHeader(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, "1");
+ }
+ if (req.url === "/404") res.statusCode = 404;
+ if (req.url === "/failure") res.statusCode = 500;
+ if (req.url === "/fatal") {
+ res.statusCode = 500;
+ res.setHeader(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1");
+ }
+ if (req.url === "/cacheable") res.setHeader("cache-control", "no-cache");
+ res.end("request-aware build props");
+ });
+ const port = await listen(server);
+
+ try {
+ const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js");
+ const { pagesRouter, apiRouter } =
+ await import("../packages/vinext/src/routing/pages-router.js");
+ const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
+ const routes = await pagesRouter(pagesDir);
+ const apiRoutes = await apiRouter(pagesDir);
+ const config = await resolveNextConfig({});
+
+ const result = await prerenderPages({
+ mode,
+ routes,
+ apiRoutes,
+ pagesDir,
+ outDir,
+ config,
+ _prodServer: { server, port },
+ });
+
+ const nonCacheableResults = result.routes.filter(
+ (route) => route.route === "/" || route.route === "/404",
+ );
+ expect(nonCacheableResults.length).toBeGreaterThanOrEqual(2);
+ for (const route of nonCacheableResults) {
+ if (mode === "export") {
+ expect(route).toMatchObject({
+ status: "error",
+ error: expect.stringContaining("not supported with output: 'export'"),
+ });
+ } else {
+ expect(route).toMatchObject({ status: "skipped", reason: "dynamic" });
+ }
+ }
+ expect(findRoute(result.routes, "/failure")).toMatchObject({
+ route: "/failure",
+ status: "error",
+ });
+ expect(findRoute(result.routes, "/fatal")).toMatchObject({
+ route: "/fatal",
+ status: "error",
+ fatal: true,
+ });
+ expect(findRoute(result.routes, "/cacheable")).toMatchObject({
+ route: "/cacheable",
+ status: "rendered",
+ });
+ expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(false);
+ expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(false);
+ expect(fs.existsSync(path.join(outDir, "cacheable.html"))).toBe(true);
+ } finally {
+ await closeServer(server);
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ },
+ );
+});
+
describe("prerenderPages — basePath export", () => {
it("requests and writes Pages exports under basePath", async () => {
const root = tmpDir("vinext-prerender-pages-basepath-");
diff --git a/tests/prod-server-prerender-endpoints.test.ts b/tests/prod-server-prerender-endpoints.test.ts
new file mode 100644
index 000000000..f93e99075
--- /dev/null
+++ b/tests/prod-server-prerender-endpoints.test.ts
@@ -0,0 +1,67 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import { VINEXT_PRERENDER_SECRET_HEADER } from "../packages/vinext/src/server/headers.js";
+
+describe("Pages production prerender endpoints", () => {
+ const roots: string[] = [];
+
+ afterEach(() => {
+ for (const root of roots.splice(0)) {
+ fs.rmSync(root, { force: true, recursive: true });
+ }
+ });
+
+ it("rejects incompatible page exports before a missing getStaticPaths returns null", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-prerender-endpoint-"));
+ roots.push(root);
+ const serverDir = path.join(root, "dist", "server");
+ fs.mkdirSync(path.join(root, "dist", "client"), { recursive: true });
+ fs.mkdirSync(serverDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(serverDir, "entry.js"),
+ [
+ "export const vinextConfig = {};",
+ "export const pageRoutes = [{",
+ " pattern: '/posts/:id',",
+ " module: {",
+ " default: Object.assign(function Page() {}, { getInitialProps: async () => ({}) }),",
+ " getStaticProps: async () => ({ props: {} }),",
+ " },",
+ "}];",
+ "export async function renderPage() { return new Response('ok'); }",
+ "export async function handleApiRoute() { return new Response('api'); }",
+ "export async function runMiddleware() { return null; }",
+ "",
+ ].join("\n"),
+ );
+ fs.writeFileSync(
+ path.join(serverDir, "vinext-server.json"),
+ JSON.stringify({ prerenderSecret: "test-prerender-secret" }),
+ );
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const { server, port } = await startProdServer({
+ host: "127.0.0.1",
+ noCompression: true,
+ outDir: path.join(root, "dist"),
+ port: 0,
+ silent: true,
+ });
+
+ try {
+ const response = await fetch(
+ `http://127.0.0.1:${port}/__vinext/prerender/pages-static-paths?pattern=/posts/:id`,
+ { headers: { [VINEXT_PRERENDER_SECRET_HEADER]: "test-prerender-secret" } },
+ );
+
+ expect(response.status).toBe(500);
+ await expect(response.text()).resolves.toContain(
+ "You can not use getInitialProps with getStaticProps",
+ );
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ });
+});