Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
62dec94
fix(pages): prevent request props from entering ISR
NathanDrake2406 Aug 23, 2026
b0e8fbe
test(pages): expect ISR bypass for request-aware app props
NathanDrake2406 Aug 23, 2026
e3f1456
fix(pages): tighten request-aware App ISR bypass
NathanDrake2406 Aug 24, 2026
c8d6046
fix(pages): no-store request-aware GSP responses and keep revalidate …
NathanDrake2406 Aug 24, 2026
3e7a8d4
fix(pages): no-store request-aware fallback shells and clear edge cac…
NathanDrake2406 Aug 24, 2026
b414ff9
fix(pages): clear edge cache headers on nonce-bearing bypass renders
NathanDrake2406 Aug 24, 2026
59f6034
fix(pages): close request-aware ISR bypass gaps
NathanDrake2406 Aug 24, 2026
ba194af
fix(pages): enforce request-aware response boundaries
NathanDrake2406 Aug 24, 2026
14d6c46
fix(pages): preserve no-store through final request paths
NathanDrake2406 Aug 24, 2026
adba844
fix(pages): retain provider no-store policies
NathanDrake2406 Aug 24, 2026
abb5ad7
fix(pages): preserve cache and export contracts
NathanDrake2406 Aug 24, 2026
3f8cc1f
fix(pages): close dynamic response cache exits
NathanDrake2406 Aug 24, 2026
0e63b3d
fix(pages): reject non-cacheable static exports
NathanDrake2406 Aug 24, 2026
5e059d1
fix(pages): fail incompatible prerender builds
NathanDrake2406 Aug 24, 2026
20772e7
fix(pages): close prerender eligibility gaps
NathanDrake2406 Aug 24, 2026
254618d
fix(pages): validate exports before static path lookup
NathanDrake2406 Aug 24, 2026
d9fd0d3
Merge remote-tracking branch 'origin/main' into codex/pr3067-owner
james-elicx Sep 9, 2026
02cfb98
Merge remote-tracking branch 'origin/main' into codex/pr3067-owner
james-elicx Sep 9, 2026
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
131 changes: 94 additions & 37 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${escapedDest}" /></head><body></body></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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${escapedDest}" /></head><body></body></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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/vinext/src/server/app-prerender-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,7 +18,7 @@ export type AppPrerenderRootParamNamesMap = Record<string, readonly string[] | u

type AppPrerenderPageRoute = {
pattern: string;
module?: {
module?: PagesDataExportModule & {
getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => unknown;
};
};
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/vinext/src/server/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
35 changes: 35 additions & 0 deletions packages/vinext/src/server/pages-data-export-compatibility.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
Loading
Loading