diff --git a/benchmarks/vinext-rolldown/tsconfig.json b/benchmarks/vinext-rolldown/tsconfig.json
index d899beda7..39af51225 100644
--- a/benchmarks/vinext-rolldown/tsconfig.json
+++ b/benchmarks/vinext-rolldown/tsconfig.json
@@ -12,7 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
- "@/*": ["./*"]
+ "@/*": ["./*"],
+ "vinext": ["../../packages/vinext/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json
index d899beda7..39af51225 100644
--- a/benchmarks/vinext/tsconfig.json
+++ b/benchmarks/vinext/tsconfig.json
@@ -12,7 +12,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
- "@/*": ["./*"]
+ "@/*": ["./*"],
+ "vinext": ["../../packages/vinext/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 71ccdcf60..d01c65e49 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -339,7 +339,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -453,16 +453,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -1815,11 +1880,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// the blanket strip loop after that call removes every remaining
// x-middleware-* header before the set is merged into the response.
_mwCtx.headers = new Headers();
- for (const [key, value] of mwResponse.headers) {
- if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
- _mwCtx.headers.append(key, value);
- }
- }
+ __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [
+ "x-middleware-next",
+ "x-middleware-rewrite",
+ ]);
} else {
// Check for redirect
if (mwResponse.status >= 300 && mwResponse.status < 400) {
@@ -1840,11 +1904,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Also save any other headers from the rewrite response
_mwCtx.headers = new Headers();
- for (const [key, value] of mwResponse.headers) {
- if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
- _mwCtx.headers.append(key, value);
- }
- }
+ __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [
+ "x-middleware-next",
+ "x-middleware-rewrite",
+ ]);
} else {
// Middleware returned a custom response
return mwResponse;
@@ -2068,21 +2131,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -2118,20 +2176,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -2321,10 +2374,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -2471,6 +2523,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -2484,15 +2538,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -2572,65 +2625,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -2656,44 +2719,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"};
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"};
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -2742,6 +2787,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -2794,8 +2846,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -2806,6 +2862,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -2821,10 +2878,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -2871,7 +2930,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -2886,22 +2952,32 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; }));
if (!_hasLocalBoundary) {
const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params);
- if (cleanResp) return cleanResp;
+ if (cleanResp) {
+ return __responseWithMiddlewareContext(
+ cleanResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
}
}
`
: ""
}
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -2931,18 +3007,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -2957,10 +3036,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts
index db8073349..606785b52 100644
--- a/packages/vinext/src/index.ts
+++ b/packages/vinext/src/index.ts
@@ -1663,6 +1663,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
"vinext/i18n-context": path.join(shimsDir, "i18n-context"),
"vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"),
"vinext/html": path.resolve(__dirname, "server", "html"),
+ "vinext/server/app-router-entry": path.resolve(__dirname, "server", "app-router-entry"),
}).flatMap(([k, v]) =>
k.startsWith("next/")
? [
@@ -2926,13 +2927,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
if (hasAppDir) {
const mwCtxEntries: [string, string][] = [];
if (result.responseHeaders) {
+ const setCookies = result.responseHeaders.getSetCookie();
for (const [key, value] of result.responseHeaders) {
// Exclude control headers that runMiddleware already
// consumed — matches the RSC entry's inline filtering.
+ if (key === "set-cookie") continue;
if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
mwCtxEntries.push([key, value]);
}
}
+ for (const cookie of setCookies) {
+ mwCtxEntries.push(["set-cookie", cookie]);
+ }
}
req.headers["x-vinext-mw-ctx"] = JSON.stringify({
h: mwCtxEntries,
diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts
index c2ae91e44..f7e0435dc 100644
--- a/packages/vinext/src/server/app-page-cache.ts
+++ b/packages/vinext/src/server/app-page-cache.ts
@@ -12,6 +12,7 @@ type AppPageCacheSetter = (
type AppPageBackgroundRegenerator = (key: string, renderFn: () => Promise) => void;
export interface AppPageCacheRenderResult {
+ headers?: Record;
html: string;
rscData: ArrayBuffer;
tags: string[];
@@ -40,7 +41,10 @@ export interface ReadAppPageCacheResponseOptions {
export interface FinalizeAppPageHtmlCacheResponseOptions {
capturedRscDataPromise: Promise | null;
cleanPathname: string;
+ consumeDynamicUsage: () => boolean;
+ consumeRenderResponseHeaders: () => Record | undefined;
getPageTags: () => string[];
+ initialRenderHeaders?: Record;
isrDebug?: AppPageDebugLogger;
isrHtmlKey: (pathname: string) => string;
isrRscKey: (pathname: string) => string;
@@ -53,8 +57,10 @@ export interface ScheduleAppPageRscCacheWriteOptions {
capturedRscDataPromise: Promise | null;
cleanPathname: string;
consumeDynamicUsage: () => boolean;
+ consumeRenderResponseHeaders: () => Record | undefined;
dynamicUsedDuringBuild: boolean;
getPageTags: () => string[];
+ initialRenderHeaders?: Record;
isrDebug?: AppPageDebugLogger;
isrRscKey: (pathname: string) => string;
isrSet: AppPageCacheSetter;
@@ -77,6 +83,70 @@ function getCachedAppPageValue(entry: ISRCacheEntry | null): CachedAppPageValue
return entry?.value.value && entry.value.value.kind === "APP_PAGE" ? entry.value.value : null;
}
+function isAppendOnlyResponseHeader(lowerKey: string): boolean {
+ return (
+ lowerKey === "set-cookie" ||
+ lowerKey === "vary" ||
+ lowerKey === "www-authenticate" ||
+ lowerKey === "proxy-authenticate"
+ );
+}
+
+function mergeResponseHeaderValues(
+ targetHeaders: Headers,
+ key: string,
+ value: string | string[],
+ mode: "fallback" | "override",
+): void {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+
+ if (isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+ return;
+ }
+
+ if (mode === "fallback" && targetHeaders.has(key)) {
+ return;
+ }
+
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]!);
+ return;
+ }
+
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+}
+
+function mergeResponseHeaders(
+ targetHeaders: Headers,
+ sourceHeaders: Record | null | undefined,
+ mode: "fallback" | "override",
+): void {
+ if (!sourceHeaders) {
+ return;
+ }
+
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+
+function headersWithCachedHeaders(
+ baseHeaders: HeadersInit,
+ cachedHeaders: Record | undefined,
+): Headers {
+ const headers = new Headers();
+ mergeResponseHeaders(headers, cachedHeaders, "fallback");
+ mergeResponseHeaders(headers, Object.fromEntries(new Headers(baseHeaders)), "override");
+ return headers;
+}
+
export function buildAppPageCachedResponse(
cachedValue: CachedAppPageValue,
options: BuildAppPageCachedResponseOptions,
@@ -97,10 +167,13 @@ export function buildAppPageCachedResponse(
return new Response(cachedValue.rscData, {
status,
- headers: {
- "Content-Type": "text/x-component; charset=utf-8",
- ...headers,
- },
+ headers: headersWithCachedHeaders(
+ {
+ "Content-Type": "text/x-component; charset=utf-8",
+ ...headers,
+ },
+ cachedValue.headers,
+ ),
});
}
@@ -110,10 +183,13 @@ export function buildAppPageCachedResponse(
return new Response(cachedValue.html, {
status,
- headers: {
- "Content-Type": "text/html; charset=utf-8",
- ...headers,
- },
+ headers: headersWithCachedHeaders(
+ {
+ "Content-Type": "text/html; charset=utf-8",
+ ...headers,
+ },
+ cachedValue.headers,
+ ),
});
}
@@ -157,13 +233,13 @@ export async function readAppPageCacheResponse(
await Promise.all([
options.isrSet(
options.isrHtmlKey(options.cleanPathname),
- buildAppPageCacheValue(revalidatedPage.html, undefined, 200),
+ buildAppPageCacheValue(revalidatedPage.html, undefined, revalidatedPage.headers, 200),
options.revalidateSeconds,
revalidatedPage.tags,
),
options.isrSet(
options.isrRscKey(options.cleanPathname),
- buildAppPageCacheValue("", revalidatedPage.rscData, 200),
+ buildAppPageCacheValue("", revalidatedPage.rscData, revalidatedPage.headers, 200),
options.revalidateSeconds,
revalidatedPage.tags,
),
@@ -225,11 +301,19 @@ export function finalizeAppPageHtmlCacheResponse(
}
chunks.push(decoder.decode());
+ const renderHeadersForCache =
+ options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders;
+
+ if (options.consumeDynamicUsage()) {
+ options.isrDebug?.("skip HTML cache write after late dynamic usage", options.cleanPathname);
+ return;
+ }
+
const pageTags = options.getPageTags();
const writes = [
options.isrSet(
htmlKey,
- buildAppPageCacheValue(chunks.join(""), undefined, 200),
+ buildAppPageCacheValue(chunks.join(""), undefined, renderHeadersForCache, 200),
options.revalidateSeconds,
pageTags,
),
@@ -240,7 +324,7 @@ export function finalizeAppPageHtmlCacheResponse(
options.capturedRscDataPromise.then((rscData) =>
options.isrSet(
rscKey,
- buildAppPageCacheValue("", rscData, 200),
+ buildAppPageCacheValue("", rscData, renderHeadersForCache, 200),
options.revalidateSeconds,
pageTags,
),
@@ -252,6 +336,8 @@ export function finalizeAppPageHtmlCacheResponse(
options.isrDebug?.("HTML cache written", htmlKey);
} catch (cacheError) {
console.error("[vinext] ISR cache write error:", cacheError);
+ } finally {
+ options.consumeRenderResponseHeaders();
}
})();
@@ -276,6 +362,8 @@ export function scheduleAppPageRscCacheWrite(
const cachePromise = (async () => {
try {
const rscData = await capturedRscDataPromise;
+ const renderHeadersForCache =
+ options.consumeRenderResponseHeaders() ?? options.initialRenderHeaders;
// Two-phase dynamic detection:
// 1. dynamicUsedDuringBuild catches searchParams-driven opt-in before the
@@ -289,7 +377,7 @@ export function scheduleAppPageRscCacheWrite(
await options.isrSet(
rscKey,
- buildAppPageCacheValue("", rscData, 200),
+ buildAppPageCacheValue("", rscData, renderHeadersForCache, 200),
options.revalidateSeconds,
options.getPageTags(),
);
diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts
index 0193a922e..c67eb708d 100644
--- a/packages/vinext/src/server/app-page-response.ts
+++ b/packages/vinext/src/server/app-page-response.ts
@@ -39,6 +39,7 @@ export interface BuildAppPageRscResponseOptions {
middlewareContext: AppPageMiddlewareContext;
params?: Record;
policy: AppPageResponsePolicy;
+ renderHeaders?: Record;
timing?: AppPageResponseTiming;
}
@@ -47,16 +48,98 @@ export interface BuildAppPageHtmlResponseOptions {
fontLinkHeader?: string;
middlewareContext: AppPageMiddlewareContext;
policy: AppPageResponsePolicy;
+ renderHeaders?: Record;
timing?: AppPageResponseTiming;
}
const STATIC_CACHE_CONTROL = "s-maxage=31536000, stale-while-revalidate";
const NO_STORE_CACHE_CONTROL = "no-store, must-revalidate";
+type ResponseHeaderSource = Headers | Record;
+type ResponseHeaderMergeMode = "fallback" | "override";
+
function buildRevalidateCacheControl(revalidateSeconds: number): string {
return `s-maxage=${revalidateSeconds}, stale-while-revalidate`;
}
+function isAppendOnlyResponseHeader(lowerKey: string): boolean {
+ return (
+ lowerKey === "set-cookie" ||
+ lowerKey === "vary" ||
+ lowerKey === "www-authenticate" ||
+ lowerKey === "proxy-authenticate"
+ );
+}
+
+function mergeResponseHeaderValues(
+ targetHeaders: Headers,
+ key: string,
+ value: string | string[],
+ mode: ResponseHeaderMergeMode,
+): void {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+
+ if (isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+ return;
+ }
+
+ if (mode === "fallback" && targetHeaders.has(key)) {
+ return;
+ }
+
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]!);
+ return;
+ }
+
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+}
+
+function mergeResponseHeaders(
+ targetHeaders: Headers,
+ sourceHeaders: ResponseHeaderSource | null | undefined,
+ mode: ResponseHeaderMergeMode,
+): void {
+ if (!sourceHeaders) {
+ return;
+ }
+
+ if (sourceHeaders instanceof Headers) {
+ const setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+
+function headersWithRenderResponseHeaders(
+ baseHeaders: ResponseHeaderSource,
+ renderHeaders: Record | undefined,
+): Headers {
+ const headers = new Headers();
+ mergeResponseHeaders(headers, renderHeaders, "fallback");
+ mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+
function applyTimingHeader(headers: Headers, timing?: AppPageResponseTiming): void {
if (!timing) {
return;
@@ -161,36 +244,24 @@ export function buildAppPageRscResponse(
body: ReadableStream,
options: BuildAppPageRscResponseOptions,
): Response {
- const headers = new Headers({
+ const baseHeaders = new Headers({
"Content-Type": "text/x-component; charset=utf-8",
Vary: "RSC, Accept",
});
if (options.params && Object.keys(options.params).length > 0) {
- headers.set("X-Vinext-Params", JSON.stringify(options.params));
+ baseHeaders.set("X-Vinext-Params", JSON.stringify(options.params));
}
if (options.policy.cacheControl) {
- headers.set("Cache-Control", options.policy.cacheControl);
+ baseHeaders.set("Cache-Control", options.policy.cacheControl);
}
if (options.policy.cacheState) {
- headers.set("X-Vinext-Cache", options.policy.cacheState);
- }
-
- if (options.middlewareContext.headers) {
- for (const [key, value] of options.middlewareContext.headers) {
- const lowerKey = key.toLowerCase();
- if (lowerKey === "set-cookie" || lowerKey === "vary") {
- headers.append(key, value);
- } else {
- // Keep parity with the old inline RSC path: middleware owns singular
- // response headers like Cache-Control here, while Set-Cookie and Vary
- // are accumulated. The HTML helper intentionally keeps its legacy
- // append-for-everything behavior below.
- headers.set(key, value);
- }
- }
+ baseHeaders.set("X-Vinext-Cache", options.policy.cacheState);
}
+ const headers = headersWithRenderResponseHeaders(baseHeaders, options.renderHeaders);
+ mergeResponseHeaders(headers, options.middlewareContext.headers, "override");
+
applyTimingHeader(headers, options.timing);
return new Response(body, {
@@ -203,29 +274,26 @@ export function buildAppPageHtmlResponse(
body: ReadableStream,
options: BuildAppPageHtmlResponseOptions,
): Response {
- const headers = new Headers({
+ const baseHeaders = new Headers({
"Content-Type": "text/html; charset=utf-8",
Vary: "RSC, Accept",
});
if (options.policy.cacheControl) {
- headers.set("Cache-Control", options.policy.cacheControl);
+ baseHeaders.set("Cache-Control", options.policy.cacheControl);
}
if (options.policy.cacheState) {
- headers.set("X-Vinext-Cache", options.policy.cacheState);
+ baseHeaders.set("X-Vinext-Cache", options.policy.cacheState);
}
if (options.draftCookie) {
- headers.append("Set-Cookie", options.draftCookie);
+ baseHeaders.append("Set-Cookie", options.draftCookie);
}
if (options.fontLinkHeader) {
- headers.set("Link", options.fontLinkHeader);
+ baseHeaders.set("Link", options.fontLinkHeader);
}
- if (options.middlewareContext.headers) {
- for (const [key, value] of options.middlewareContext.headers) {
- headers.append(key, value);
- }
- }
+ const headers = headersWithRenderResponseHeaders(baseHeaders, options.renderHeaders);
+ mergeResponseHeaders(headers, options.middlewareContext.headers, "override");
applyTimingHeader(headers, options.timing);
diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts
index e61e6d6b8..82d49d77b 100644
--- a/packages/vinext/src/server/app-route-handler-execution.ts
+++ b/packages/vinext/src/server/app-route-handler-execution.ts
@@ -60,10 +60,9 @@ export interface ExecuteAppRouteHandlerOptions extends RunAppRouteHandlerOptions
buildPageCacheTags: (pathname: string, extraTags: string[]) => string[];
clearRequestContext: () => void;
cleanPathname: string;
+ consumeRenderResponseHeaders: () => Record | undefined;
executionContext: ExecutionContextLike | null;
- getAndClearPendingCookies: () => string[];
getCollectedFetchTags: () => string[];
- getDraftModeCookieHeader: () => string | null | undefined;
handler: AppRouteHandlerModule;
isAutoHead: boolean;
isProduction: boolean;
@@ -162,37 +161,50 @@ export async function executeAppRouteHandler(
options.executionContext?.waitUntil(routeWritePromise);
}
- const pendingCookies = options.getAndClearPendingCookies();
- const draftCookie = options.getDraftModeCookieHeader();
+ const renderResponseHeaders = options.consumeRenderResponseHeaders();
options.clearRequestContext();
return applyRouteHandlerMiddlewareContext(
finalizeRouteHandlerResponse(response, {
- pendingCookies,
- draftCookie,
isHead: options.isAutoHead,
+ renderHeaders: renderResponseHeaders,
}),
options.middlewareContext,
);
} catch (error) {
- options.getAndClearPendingCookies();
+ const renderResponseHeaders = options.consumeRenderResponseHeaders();
const specialError = resolveAppRouteHandlerSpecialError(error, options.request.url);
options.clearRequestContext();
if (specialError) {
if (specialError.kind === "redirect") {
return applyRouteHandlerMiddlewareContext(
- new Response(null, {
- status: specialError.statusCode,
- headers: { Location: specialError.location },
- }),
+ finalizeRouteHandlerResponse(
+ new Response(null, {
+ status: specialError.statusCode,
+ headers: { Location: specialError.location },
+ }),
+ {
+ isHead: false,
+ renderHeaders: renderResponseHeaders,
+ },
+ ),
options.middlewareContext,
+ {
+ applyRewriteStatus: false,
+ },
);
}
return applyRouteHandlerMiddlewareContext(
- new Response(null, { status: specialError.statusCode }),
+ finalizeRouteHandlerResponse(new Response(null, { status: specialError.statusCode }), {
+ isHead: false,
+ renderHeaders: renderResponseHeaders,
+ }),
options.middlewareContext,
+ {
+ applyRewriteStatus: false,
+ },
);
}
diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts
index 51c30046e..68d028345 100644
--- a/packages/vinext/src/server/app-route-handler-response.ts
+++ b/packages/vinext/src/server/app-route-handler-response.ts
@@ -11,12 +11,18 @@ export interface BuildRouteHandlerCachedResponseOptions {
revalidateSeconds: number;
}
+export interface ApplyRouteHandlerMiddlewareContextOptions {
+ applyRewriteStatus?: boolean;
+}
+
export interface FinalizeRouteHandlerResponseOptions {
- pendingCookies: string[];
- draftCookie?: string | null;
isHead: boolean;
+ renderHeaders?: Record;
}
+type ResponseHeaderSource = Headers | Record;
+type ResponseHeaderMergeMode = "fallback" | "override";
+
function buildRouteHandlerCacheControl(
cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"],
revalidateSeconds: number,
@@ -28,26 +34,109 @@ function buildRouteHandlerCacheControl(
return `s-maxage=${revalidateSeconds}, stale-while-revalidate`;
}
+function isAppendOnlyResponseHeader(lowerKey: string): boolean {
+ return (
+ lowerKey === "set-cookie" ||
+ lowerKey === "vary" ||
+ lowerKey === "www-authenticate" ||
+ lowerKey === "proxy-authenticate"
+ );
+}
+
+function mergeResponseHeaderValues(
+ targetHeaders: Headers,
+ key: string,
+ value: string | string[],
+ mode: ResponseHeaderMergeMode,
+): void {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+
+ if (isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+ return;
+ }
+
+ if (mode === "fallback" && targetHeaders.has(key)) {
+ return;
+ }
+
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]!);
+ return;
+ }
+
+ for (const item of values) {
+ targetHeaders.append(key, item);
+ }
+}
+
+function mergeResponseHeaders(
+ targetHeaders: Headers,
+ sourceHeaders: ResponseHeaderSource | null | undefined,
+ mode: ResponseHeaderMergeMode,
+): void {
+ if (!sourceHeaders) {
+ return;
+ }
+
+ if (sourceHeaders instanceof Headers) {
+ const setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+
+function headersWithRenderResponseHeaders(
+ baseHeaders: ResponseHeaderSource,
+ renderHeaders: Record | undefined,
+): Headers {
+ const headers = new Headers();
+ mergeResponseHeaders(headers, renderHeaders, "fallback");
+ mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+
export function applyRouteHandlerMiddlewareContext(
response: Response,
middlewareContext: RouteHandlerMiddlewareContext,
+ options?: ApplyRouteHandlerMiddlewareContextOptions,
): Response {
- if (!middlewareContext.headers && middlewareContext.status == null) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareContext.status;
+
+ if (!middlewareContext.headers && rewriteStatus == null) {
return response;
}
const responseHeaders = new Headers(response.headers);
- if (middlewareContext.headers) {
- for (const [key, value] of middlewareContext.headers) {
- responseHeaders.append(key, value);
- }
- }
+ mergeResponseHeaders(responseHeaders, middlewareContext.headers, "override");
- return new Response(response.body, {
- status: middlewareContext.status ?? response.status,
- statusText: response.statusText,
+ const status = rewriteStatus ?? response.status;
+ const responseInit: ResponseInit = {
+ status,
headers: responseHeaders,
- });
+ };
+
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+
+ return new Response(response.body, responseInit);
}
export function buildRouteHandlerCachedResponse(
@@ -109,22 +198,20 @@ export function finalizeRouteHandlerResponse(
response: Response,
options: FinalizeRouteHandlerResponseOptions,
): Response {
- const { pendingCookies, draftCookie, isHead } = options;
- if (pendingCookies.length === 0 && !draftCookie && !isHead) {
+ const { isHead, renderHeaders } = options;
+ if (!renderHeaders && !isHead) {
return response;
}
- const headers = new Headers(response.headers);
- for (const cookie of pendingCookies) {
- headers.append("Set-Cookie", cookie);
- }
- if (draftCookie) {
- headers.append("Set-Cookie", draftCookie);
- }
-
- return new Response(isHead ? null : response.body, {
+ const headers = headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ const responseInit: ResponseInit = {
status: response.status,
- statusText: response.statusText,
headers,
- });
+ };
+
+ if (response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+
+ return new Response(isHead ? null : response.body, responseInit);
}
diff --git a/packages/vinext/src/server/isr-cache.ts b/packages/vinext/src/server/isr-cache.ts
index 860aa4681..b455fdb75 100644
--- a/packages/vinext/src/server/isr-cache.ts
+++ b/packages/vinext/src/server/isr-cache.ts
@@ -131,13 +131,14 @@ export function buildPagesCacheValue(
export function buildAppPageCacheValue(
html: string,
rscData?: ArrayBuffer,
+ headers?: Record,
status?: number,
): CachedAppPageValue {
return {
kind: "APP_PAGE",
html,
rscData,
- headers: undefined,
+ headers,
postponed: undefined,
status,
};
diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts
index 170fcaf6c..af49eeaae 100644
--- a/packages/vinext/src/shims/headers.ts
+++ b/packages/vinext/src/shims/headers.ts
@@ -32,14 +32,91 @@ export interface HeadersContext {
export type HeadersAccessPhase = "render" | "action" | "route-handler";
+type RenderSetCookieSource = "cookie" | "draft" | "header";
+
+interface RenderSetCookieEntry {
+ source: RenderSetCookieSource;
+ value: string;
+}
+
+interface RenderResponseHeaderEntry {
+ name: string;
+ values: string[];
+}
+
+interface RenderResponseHeaders {
+ headers: Map;
+ setCookies: RenderSetCookieEntry[];
+}
+
export type VinextHeadersShimState = {
headersContext: HeadersContext | null;
dynamicUsageDetected: boolean;
- pendingSetCookies: string[];
- draftModeCookieHeader: string | null;
+ renderResponseHeaders: RenderResponseHeaders;
phase: HeadersAccessPhase;
};
+function createRenderResponseHeaders(): RenderResponseHeaders {
+ return {
+ headers: new Map(),
+ setCookies: [],
+ };
+}
+
+function serializeRenderResponseHeaders(
+ renderResponseHeaders: RenderResponseHeaders,
+): Record | undefined {
+ if (renderResponseHeaders.headers.size === 0 && renderResponseHeaders.setCookies.length === 0) {
+ return undefined;
+ }
+
+ const serialized: Record = {};
+
+ for (const entry of renderResponseHeaders.headers.values()) {
+ if (entry.values.length === 1) {
+ serialized[entry.name] = entry.values[0]!;
+ continue;
+ }
+ if (entry.values.length > 1) {
+ serialized[entry.name] = [...entry.values];
+ }
+ }
+
+ if (renderResponseHeaders.setCookies.length > 0) {
+ serialized["set-cookie"] = renderResponseHeaders.setCookies.map((entry) => entry.value);
+ }
+
+ return Object.keys(serialized).length > 0 ? serialized : undefined;
+}
+
+function deserializeRenderResponseHeaders(
+ serialized?: Record,
+): RenderResponseHeaders {
+ const renderResponseHeaders = createRenderResponseHeaders();
+
+ if (!serialized) {
+ return renderResponseHeaders;
+ }
+
+ for (const [key, value] of Object.entries(serialized)) {
+ if (key.toLowerCase() === "set-cookie") {
+ const values = Array.isArray(value) ? value : [value];
+ renderResponseHeaders.setCookies = values.map((item) => ({
+ source: "header",
+ value: item,
+ }));
+ continue;
+ }
+
+ renderResponseHeaders.headers.set(key.toLowerCase(), {
+ name: key,
+ values: Array.isArray(value) ? [...value] : [value],
+ });
+ }
+
+ return renderResponseHeaders;
+}
+
// NOTE:
// - This shim can be loaded under multiple module specifiers in Vite's
// multi-environment setup (RSC/SSR). Store the AsyncLocalStorage on
@@ -56,8 +133,7 @@ const _als = (_g[_ALS_KEY] ??=
const _fallbackState = (_g[_FALLBACK_KEY] ??= {
headersContext: null,
dynamicUsageDetected: false,
- pendingSetCookies: [],
- draftModeCookieHeader: null,
+ renderResponseHeaders: createRenderResponseHeaders(),
phase: "render",
} satisfies VinextHeadersShimState) as VinextHeadersShimState;
const EXPIRED_COOKIE_DATE = new Date(0).toUTCString();
@@ -66,7 +142,31 @@ function _getState(): VinextHeadersShimState {
if (isInsideUnifiedScope()) {
return getRequestContext();
}
- return _als.getStore() ?? _fallbackState;
+
+ const state = _als.getStore();
+ return state ?? _fallbackState;
+}
+
+function _appendRenderResponseHeaderWithSource(
+ name: string,
+ value: string,
+ source: RenderSetCookieSource,
+): void {
+ const state = _getState();
+ if (name.toLowerCase() === "set-cookie") {
+ state.renderResponseHeaders.setCookies.push({ source, value });
+ return;
+ }
+ const lowerName = name.toLowerCase();
+ const existing = state.renderResponseHeaders.headers.get(lowerName);
+ if (existing) {
+ existing.values.push(value);
+ return;
+ }
+ state.renderResponseHeaders.headers.set(lowerName, {
+ name,
+ values: [value],
+ });
}
/**
@@ -142,6 +242,14 @@ export function consumeDynamicUsage(): boolean {
return used;
}
+export function peekDynamicUsage(): boolean {
+ return _getState().dynamicUsageDetected;
+}
+
+export function restoreDynamicUsage(used: boolean): void {
+ _getState().dynamicUsageDetected = used;
+}
+
function _setStatePhase(
state: VinextHeadersShimState,
phase: HeadersAccessPhase,
@@ -183,8 +291,13 @@ export function setHeadersContext(ctx: HeadersContext | null): void {
if (ctx !== null) {
state.headersContext = ctx;
state.dynamicUsageDetected = false;
- state.pendingSetCookies = [];
- state.draftModeCookieHeader = null;
+ state.renderResponseHeaders = createRenderResponseHeaders();
+ const legacyState = state as VinextHeadersShimState & {
+ pendingSetCookies?: string[];
+ draftModeCookieHeader?: string | null;
+ };
+ legacyState.pendingSetCookies = [];
+ legacyState.draftModeCookieHeader = null;
state.phase = "render";
} else {
state.headersContext = null;
@@ -212,6 +325,7 @@ export function runWithHeadersContext(
uCtx.dynamicUsageDetected = false;
uCtx.pendingSetCookies = [];
uCtx.draftModeCookieHeader = null;
+ uCtx.renderResponseHeaders = createRenderResponseHeaders();
uCtx.phase = "render";
}, fn);
}
@@ -219,8 +333,7 @@ export function runWithHeadersContext(
const state: VinextHeadersShimState = {
headersContext: ctx,
dynamicUsageDetected: false,
- pendingSetCookies: [],
- draftModeCookieHeader: null,
+ renderResponseHeaders: createRenderResponseHeaders(),
phase: "render",
};
@@ -566,11 +679,19 @@ export function cookies(): Promise & RequestCookies {
/**
* Get and clear all pending Set-Cookie headers generated by cookies().set()/delete().
* Called by the framework after rendering to attach headers to the response.
+ *
+ * @deprecated Prefer consumeRenderResponseHeaders() when you need the full
+ * render-time response header set.
*/
export function getAndClearPendingCookies(): string[] {
const state = _getState();
- const cookies = state.pendingSetCookies;
- state.pendingSetCookies = [];
+ const cookies = state.renderResponseHeaders.setCookies
+ .filter((entry) => entry.source === "cookie")
+ .map((entry) => entry.value);
+ if (cookies.length === 0) return [];
+ state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter(
+ (entry) => entry.source !== "cookie",
+ );
return cookies;
}
@@ -597,12 +718,60 @@ function getDraftSecret(): string {
/**
* Get any Set-Cookie header generated by draftMode().enable()/disable().
* Called by the framework after rendering to attach the header to the response.
+ *
+ * @deprecated Prefer consumeRenderResponseHeaders() when you need the full
+ * render-time response header set.
*/
export function getDraftModeCookieHeader(): string | null {
const state = _getState();
- const header = state.draftModeCookieHeader;
- state.draftModeCookieHeader = null;
- return header;
+ const draftEntries = state.renderResponseHeaders.setCookies.filter(
+ (entry) => entry.source === "draft",
+ );
+ if (draftEntries.length === 0) return null;
+ state.renderResponseHeaders.setCookies = state.renderResponseHeaders.setCookies.filter(
+ (entry) => entry.source !== "draft",
+ );
+ return draftEntries[draftEntries.length - 1]?.value ?? null;
+}
+
+export function appendRenderResponseHeader(name: string, value: string): void {
+ _appendRenderResponseHeaderWithSource(name, value, "header");
+}
+
+export function setRenderResponseHeader(name: string, value: string): void {
+ const state = _getState();
+ if (name.toLowerCase() === "set-cookie") {
+ state.renderResponseHeaders.setCookies = [{ source: "header", value }];
+ return;
+ }
+ state.renderResponseHeaders.headers.set(name.toLowerCase(), {
+ name,
+ values: [value],
+ });
+}
+
+export function deleteRenderResponseHeader(name: string): void {
+ const state = _getState();
+ if (name.toLowerCase() === "set-cookie") {
+ state.renderResponseHeaders.setCookies = [];
+ return;
+ }
+ state.renderResponseHeaders.headers.delete(name.toLowerCase());
+}
+
+export function peekRenderResponseHeaders(): Record | undefined {
+ return serializeRenderResponseHeaders(_getState().renderResponseHeaders);
+}
+
+export function restoreRenderResponseHeaders(serialized?: Record): void {
+ _getState().renderResponseHeaders = deserializeRenderResponseHeaders(serialized);
+}
+
+export function consumeRenderResponseHeaders(): Record | undefined {
+ const state = _getState();
+ const serialized = serializeRenderResponseHeaders(state.renderResponseHeaders);
+ state.renderResponseHeaders = createRenderResponseHeaders();
+ return serialized;
}
interface DraftModeResult {
@@ -642,7 +811,11 @@ export async function draftMode(): Promise {
}
const secure =
typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : "";
- state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`;
+ _appendRenderResponseHeaderWithSource(
+ "Set-Cookie",
+ `${DRAFT_MODE_COOKIE}=${secret}; Path=/; HttpOnly; SameSite=Lax${secure}`,
+ "draft",
+ );
},
disable(): void {
if (state.headersContext?.accessError) {
@@ -653,7 +826,11 @@ export async function draftMode(): Promise {
}
const secure =
typeof process !== "undefined" && process.env?.NODE_ENV === "production" ? "; Secure" : "";
- state.draftModeCookieHeader = `${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`;
+ _appendRenderResponseHeaderWithSource(
+ "Set-Cookie",
+ `${DRAFT_MODE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`,
+ "draft",
+ );
},
};
}
@@ -783,12 +960,12 @@ class RequestCookies {
if (opts?.secure) parts.push("Secure");
if (opts?.sameSite) parts.push(`SameSite=${opts.sameSite}`);
- _getState().pendingSetCookies.push(parts.join("; "));
+ _appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie");
return this;
}
/**
- * Delete a cookie by emitting an expired Set-Cookie header.
+ * Delete a cookie by setting it with Max-Age=0.
*/
delete(nameOrOptions: string | { name: string; path?: string; domain?: string }): this {
const name = typeof nameOrOptions === "string" ? nameOrOptions : nameOrOptions.name;
@@ -805,7 +982,7 @@ class RequestCookies {
const parts = [`${name}=`, `Path=${path}`];
if (domain) parts.push(`Domain=${domain}`);
parts.push(`Expires=${EXPIRED_COOKIE_DATE}`);
- _getState().pendingSetCookies.push(parts.join("; "));
+ _appendRenderResponseHeaderWithSource("Set-Cookie", parts.join("; "), "cookie");
return this;
}
diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts
index eab6aa47d..893cb5ca9 100644
--- a/packages/vinext/src/shims/unified-request-context.ts
+++ b/packages/vinext/src/shims/unified-request-context.ts
@@ -47,6 +47,10 @@ export interface UnifiedRequestContext
// ── request-context.ts ─────────────────────────────────────────────
/** Cloudflare Workers ExecutionContext, or null on Node.js dev. */
executionContext: ExecutionContextLike | null;
+ /** Legacy cookie queue kept for compatibility with existing unified-scope tests. */
+ pendingSetCookies: string[];
+ /** Legacy draft-mode header mirror kept for compatibility with existing tests. */
+ draftModeCookieHeader: string | null;
}
// ---------------------------------------------------------------------------
@@ -84,6 +88,10 @@ export function createRequestContext(opts?: Partial): Uni
dynamicUsageDetected: false,
pendingSetCookies: [],
draftModeCookieHeader: null,
+ renderResponseHeaders: {
+ headers: new Map(),
+ setCookies: [],
+ },
phase: "render",
i18nContext: null,
serverContext: null,
@@ -129,13 +137,13 @@ export function runWithUnifiedStateMutation(
const childCtx = { ...parentCtx };
// NOTE: This is a shallow clone. Array fields (pendingSetCookies,
// serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), the
- // _privateCache Map, and object fields (headersContext, i18nContext,
- // serverContext, ssrContext, executionContext, requestScopedCacheLife)
- // still share references with the parent until replaced. The mutate
- // callback must replace those reference-typed slices (for example
- // `ctx.currentRequestTags = []`) rather than mutating them in-place (for
- // example `ctx.currentRequestTags.push(...)`) or the parent scope will
- // observe those changes too. Keep this enumeration in sync with
+ // _privateCache Map, and object fields (headersContext, renderResponseHeaders,
+ // i18nContext, serverContext, ssrContext, executionContext,
+ // requestScopedCacheLife) still share references with the parent until
+ // replaced. The mutate callback must replace those reference-typed slices
+ // (for example `ctx.currentRequestTags = []`) rather than mutating them
+ // in-place (for example `ctx.currentRequestTags.push(...)`) or the parent
+ // scope will observe those changes too. Keep this enumeration in sync with
// UnifiedRequestContext: when adding a new reference-typed field, add it
// here too and verify callers still follow the replace-not-mutate rule.
mutate(childCtx);
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index 1c12f3069..34dea0d33 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -43,7 +43,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -157,16 +157,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -1765,21 +1830,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -1815,20 +1875,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -1993,10 +2048,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -2143,6 +2197,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -2156,15 +2212,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -2244,65 +2299,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -2328,44 +2393,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -2414,6 +2461,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -2466,8 +2520,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -2478,6 +2536,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -2493,10 +2552,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -2543,7 +2604,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -2553,15 +2621,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// when the error falls through to global-error.tsx.
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -2591,18 +2662,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -2617,10 +2691,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
@@ -2672,7 +2746,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -2786,16 +2860,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -4397,21 +4536,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -4447,20 +4581,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -4625,10 +4754,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -4775,6 +4903,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -4788,15 +4918,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -4876,65 +5005,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -4960,44 +5099,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -5046,6 +5167,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -5098,8 +5226,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -5110,6 +5242,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -5125,10 +5258,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -5175,7 +5310,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -5185,15 +5327,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// when the error falls through to global-error.tsx.
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -5223,18 +5368,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -5249,10 +5397,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
@@ -5304,7 +5452,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -5418,16 +5566,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -7056,21 +7269,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -7106,20 +7314,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -7284,10 +7487,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -7434,6 +7636,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -7447,15 +7651,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -7535,65 +7738,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -7619,44 +7832,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -7705,6 +7900,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -7757,8 +7959,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -7769,6 +7975,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -7784,10 +7991,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -7834,7 +8043,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -7847,20 +8063,30 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; }));
if (!_hasLocalBoundary) {
const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params);
- if (cleanResp) return cleanResp;
+ if (cleanResp) {
+ return __responseWithMiddlewareContext(
+ cleanResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
}
}
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -7890,18 +8116,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -7916,10 +8145,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
@@ -7971,7 +8200,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -8085,16 +8314,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -9726,21 +10020,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -9776,20 +10065,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -9954,10 +10238,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -10104,6 +10387,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -10117,15 +10402,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -10205,65 +10489,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -10289,44 +10583,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -10375,6 +10651,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -10427,8 +10710,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -10439,6 +10726,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -10454,10 +10742,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -10504,7 +10794,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -10514,15 +10811,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// when the error falls through to global-error.tsx.
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -10552,18 +10852,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -10578,10 +10881,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
@@ -10633,7 +10936,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -10747,16 +11050,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -12362,21 +12730,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -12412,20 +12775,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -12590,10 +12948,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -12740,6 +13097,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -12753,15 +13112,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -12841,65 +13199,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -12925,44 +13293,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -13011,6 +13361,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -13063,8 +13420,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -13075,6 +13436,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -13090,10 +13452,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -13140,7 +13504,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -13150,15 +13521,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// when the error falls through to global-error.tsx.
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -13188,18 +13562,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -13214,10 +13591,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
@@ -13269,7 +13646,7 @@ function renderToReadableStream(model, options) {
}
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
-import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
+import { setHeadersContext, headersContextFromRequest, consumeDynamicUsage, peekDynamicUsage, restoreDynamicUsage, peekRenderResponseHeaders, restoreRenderResponseHeaders, consumeRenderResponseHeaders, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
import { NextRequest, NextFetchEvent } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { LayoutSegmentProvider } from "vinext/layout-segment-context";
@@ -13383,16 +13760,81 @@ function __pageCacheTags(pathname, extraTags) {
}
return tags;
}
-// Note: cache entries are written with \`headers: undefined\`. Next.js stores
-// response headers (e.g. set-cookie from cookies().set() during render) in the
-// cache entry so they can be replayed on HIT. We don't do this because:
-// 1. Pages that call cookies().set() during render trigger dynamicUsedDuringRender,
-// which opts them out of ISR caching before we reach the write path.
-// 2. Custom response headers set via next/headers are not yet captured separately
-// from the live Response object in vinext's server pipeline.
-// In practice this means ISR-cached responses won't replay render-time set-cookie
-// headers — but that case is already prevented by the dynamic-usage opt-out.
-// TODO: capture render-time response headers for full Next.js parity.
+function __isAppendOnlyResponseHeader(lowerKey) {
+ return lowerKey === "set-cookie" || lowerKey === "vary" || lowerKey === "www-authenticate" || lowerKey === "proxy-authenticate";
+}
+function __mergeResponseHeaderValues(targetHeaders, key, value, mode) {
+ const lowerKey = key.toLowerCase();
+ const values = Array.isArray(value) ? value : [value];
+ if (__isAppendOnlyResponseHeader(lowerKey)) {
+ for (const item of values) targetHeaders.append(key, item);
+ return;
+ }
+ if (mode === "fallback" && targetHeaders.has(key)) return;
+ targetHeaders.delete(key);
+ if (values.length === 1) {
+ targetHeaders.set(key, values[0]);
+ return;
+ }
+ for (const item of values) targetHeaders.append(key, item);
+}
+function __mergeResponseHeaders(targetHeaders, sourceHeaders, mode) {
+ if (!sourceHeaders) return;
+ if (sourceHeaders instanceof Headers) {
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ if (key.toLowerCase() === "set-cookie") {
+ continue;
+ }
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ return;
+ }
+ for (const [key, value] of Object.entries(sourceHeaders)) {
+ __mergeResponseHeaderValues(targetHeaders, key, value, mode);
+ }
+}
+function __headersWithRenderResponseHeaders(baseHeaders, renderHeaders) {
+ const headers = new Headers();
+ __mergeResponseHeaders(headers, renderHeaders, "fallback");
+ __mergeResponseHeaders(headers, baseHeaders, "override");
+ return headers;
+}
+function __applyMiddlewareResponseHeaders(targetHeaders, middlewareHeaders) {
+ __mergeResponseHeaders(targetHeaders, middlewareHeaders, "override");
+}
+function __copyResponseHeaders(targetHeaders, sourceHeaders, excludedKeys) {
+ const __excluded = new Set(excludedKeys.map((key) => key.toLowerCase()));
+ const __setCookies = sourceHeaders.getSetCookie();
+ for (const [key, value] of sourceHeaders) {
+ const __lowerKey = key.toLowerCase();
+ if (__excluded.has(__lowerKey) || __lowerKey === "set-cookie") continue;
+ targetHeaders.append(key, value);
+ }
+ if (!__excluded.has("set-cookie")) {
+ for (const cookie of __setCookies) {
+ targetHeaders.append("Set-Cookie", cookie);
+ }
+ }
+}
+function __responseWithMiddlewareContext(response, middlewareCtx, renderHeaders, options) {
+ const rewriteStatus = options?.applyRewriteStatus === false ? null : middlewareCtx?.status;
+ if (!middlewareCtx?.headers && rewriteStatus == null && !renderHeaders) return response;
+ const responseHeaders = __headersWithRenderResponseHeaders(response.headers, renderHeaders);
+ __applyMiddlewareResponseHeaders(responseHeaders, middlewareCtx?.headers);
+ const status = rewriteStatus ?? response.status;
+ const responseInit = {
+ status,
+ headers: responseHeaders,
+ };
+ if (status === response.status && response.statusText) {
+ responseInit.statusText = response.statusText;
+ }
+ return new Response(response.body, responseInit);
+}
const __pendingRegenerations = new Map();
function __triggerBackgroundRegeneration(key, renderFn) {
if (__pendingRegenerations.has(key)) return;
@@ -15100,11 +15542,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// the blanket strip loop after that call removes every remaining
// x-middleware-* header before the set is merged into the response.
_mwCtx.headers = new Headers();
- for (const [key, value] of mwResponse.headers) {
- if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
- _mwCtx.headers.append(key, value);
- }
- }
+ __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [
+ "x-middleware-next",
+ "x-middleware-rewrite",
+ ]);
} else {
// Check for redirect
if (mwResponse.status >= 300 && mwResponse.status < 400) {
@@ -15125,11 +15566,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
// Also save any other headers from the rewrite response
_mwCtx.headers = new Headers();
- for (const [key, value] of mwResponse.headers) {
- if (key !== "x-middleware-next" && key !== "x-middleware-rewrite") {
- _mwCtx.headers.append(key, value);
- }
- }
+ __copyResponseHeaders(_mwCtx.headers, mwResponse.headers, [
+ "x-middleware-next",
+ "x-middleware-rewrite",
+ ]);
} else {
// Middleware returned a custom response
return mwResponse;
@@ -15351,21 +15791,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// and receive a page HTML instead of RSC stream). Instead, we return a 200
// with x-action-redirect header that the client entry detects and handles.
if (actionRedirect) {
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
+ const actionRenderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- const redirectHeaders = new Headers({
+ const redirectHeaders = __headersWithRenderResponseHeaders({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
- });
- for (const cookie of actionPendingCookies) {
- redirectHeaders.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie);
+ }, actionRenderHeaders);
// Send an empty RSC-like body (client will navigate instead of parsing)
return new Response("", { status: 200, headers: redirectHeaders });
}
@@ -15401,20 +15836,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
- const actionPendingCookies = getAndClearPendingCookies();
- const actionDraftCookie = getDraftModeCookieHeader();
-
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
- const actionResponse = new Response(rscStream, { headers: actionHeaders });
- if (actionPendingCookies.length > 0 || actionDraftCookie) {
- for (const cookie of actionPendingCookies) {
- actionResponse.headers.append("Set-Cookie", cookie);
- }
- if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
- }
- return actionResponse;
+ const actionRenderHeaders = consumeRenderResponseHeaders();
+
+ const actionHeaders = __headersWithRenderResponseHeaders({
+ "Content-Type": "text/x-component; charset=utf-8",
+ "Vary": "RSC, Accept",
+ }, actionRenderHeaders);
+ return new Response(rscStream, { headers: actionHeaders });
} catch (err) {
- getAndClearPendingCookies(); // Clear pending cookies on error
+ consumeRenderResponseHeaders(); // Clear any pending render-time response headers on error
console.error("[vinext] Server action error:", err);
_reportRequestError(
err instanceof Error ? err : new Error(String(err)),
@@ -15579,10 +16009,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext(null);
},
consumeDynamicUsage,
+ consumeRenderResponseHeaders,
executionContext: _getRequestExecutionContext(),
- getAndClearPendingCookies,
getCollectedFetchTags,
- getDraftModeCookieHeader,
handler,
handlerFn,
i18n: __i18nConfig,
@@ -15729,6 +16158,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __revalFontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads() };
const __revalSsrEntry = await import.meta.viteRsc.loadModule("ssr", "index");
const __revalHtmlStream = await __revalSsrEntry.handleSsr(__revalRscForSsr, _getNavigationContext(), __revalFontData);
+ const __freshRscData = await __rscDataPromise;
+ const __renderHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Collect the full HTML string from the stream
@@ -15742,15 +16173,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
__revalChunks.push(__revalDecoder.decode());
const __freshHtml = __revalChunks.join("");
- const __freshRscData = await __rscDataPromise;
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
- return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
+ return { html: __freshHtml, rscData: __freshRscData, headers: __renderHeaders, tags: __pageTags };
});
},
scheduleBackgroundRegeneration: __triggerBackgroundRegeneration,
});
if (__cachedPageResponse) {
- return __cachedPageResponse;
+ return __responseWithMiddlewareContext(__cachedPageResponse, _mwCtx);
}
}
@@ -15830,65 +16260,75 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}
- let element;
- try {
- element = await buildPageElement(route, params, interceptOpts, url.searchParams);
- } catch (buildErr) {
- // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components
- if (buildErr && typeof buildErr === "object" && "digest" in buildErr) {
- const digest = String(buildErr.digest);
+ // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
+ async function handleRenderError(err, fallbackOpts) {
+ if (err && typeof err === "object" && "digest" in err) {
+ const digest = String(err.digest);
if (digest.startsWith("NEXT_REDIRECT;")) {
const parts = digest.split(";");
const redirectUrl = decodeURIComponent(parts[2]);
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
+ const renderResponseHeaders = consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
+ return __responseWithMiddlewareContext(new Response(null, {
+ status: statusCode,
+ headers: { Location: new URL(redirectUrl, request.url).toString() },
+ }), _mwCtx, renderResponseHeaders, { applyRewriteStatus: false });
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
+ const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, {
+ matchedParams: params,
+ ...fallbackOpts,
+ });
+ const renderResponseHeaders = consumeRenderResponseHeaders();
+ if (fallbackResp) {
+ return __responseWithMiddlewareContext(
+ fallbackResp,
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
+ }
setHeadersContext(null);
setNavigationContext(null);
const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
+ return __responseWithMiddlewareContext(
+ new Response(statusText, { status: statusCode }),
+ _mwCtx,
+ renderResponseHeaders,
+ { applyRewriteStatus: false },
+ );
}
}
+ return null;
+ }
+
+ let element;
+ try {
+ element = await buildPageElement(route, params, interceptOpts, url.searchParams);
+ } catch (buildErr) {
+ const specialResponse = await handleRenderError(buildErr);
+ if (specialResponse) return specialResponse;
// Non-special error (e.g. generateMetadata() threw) — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw buildErr;
}
+ const __buildRenderResponseHeaders = peekRenderResponseHeaders();
+ const __buildDynamicUsage = peekDynamicUsage();
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
// rscCssTransform — no manual loadCss() call needed.
- // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim
- async function handleRenderError(err) {
- if (err && typeof err === "object" && "digest" in err) {
- const digest = String(err.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params });
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
- }
- }
- return null;
- }
-
// Pre-render layout components to catch notFound()/redirect() thrown from layouts.
// In Next.js, each layout level has its own NotFoundBoundary. When a layout throws
// notFound(), the parent layout's boundary catches it and renders the parent's
@@ -15914,44 +16354,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const lr = LayoutComp({ params: asyncParams, children: null });
if (lr && typeof lr === "object" && typeof lr.then === "function") await lr;
} catch (layoutErr) {
- if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
- const digest = String(layoutErr.digest);
- if (digest.startsWith("NEXT_REDIRECT;")) {
- const parts = digest.split(";");
- const redirectUrl = decodeURIComponent(parts[2]);
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
- setHeadersContext(null);
- setNavigationContext(null);
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
- }
- if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
- const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
- // Find the not-found component from the parent level (the boundary that
- // would catch this in Next.js). Walk up from the throwing layout to find
- // the nearest not-found at a parent layout's directory.
- let parentNotFound = null;
- if (route.notFounds) {
- for (let pi = li - 1; pi >= 0; pi--) {
- if (route.notFounds[pi]?.default) {
- parentNotFound = route.notFounds[pi].default;
- break;
- }
- }
+ // Find the not-found component from the parent level (the boundary that
+ // would catch this in Next.js). Walk up from the throwing layout to find
+ // the nearest not-found at a parent layout's directory.
+ let parentNotFound = null;
+ if (route.notFounds) {
+ for (let pi = li - 1; pi >= 0; pi--) {
+ if (route.notFounds[pi]?.default) {
+ parentNotFound = route.notFounds[pi].default;
+ break;
}
- if (!parentNotFound) parentNotFound = null;
- // Wrap in only the layouts above the throwing one
- const parentLayouts = route.layouts.slice(0, li);
- const fallbackResp = await renderHTTPAccessFallbackPage(
- route, statusCode, isRscRequest, request,
- { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params }
- );
- if (fallbackResp) return fallbackResp;
- setHeadersContext(null);
- setNavigationContext(null);
- const statusText = statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found";
- return new Response(statusText, { status: statusCode });
}
}
+ if (!parentNotFound) parentNotFound = null;
+ // Wrap in only the layouts above the throwing one
+ const parentLayouts = route.layouts.slice(0, li);
+ const specialResponse = await handleRenderError(layoutErr, {
+ boundaryComponent: parentNotFound,
+ layouts: parentLayouts,
+ });
+ if (specialResponse) return specialResponse;
// Not a special error — let it propagate through normal RSC rendering
}
}
@@ -16000,6 +16422,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
if (_pageProbeResult instanceof Response) return _pageProbeResult;
+ // The sync pre-render probes above are only for catching redirect/notFound
+ // before streaming begins. Discard any render-time response headers they
+ // may have produced while preserving headers generated during buildPageElement
+ // (e.g. generateMetadata), since those are part of the real render output.
+ restoreRenderResponseHeaders(__buildRenderResponseHeaders);
+ restoreDynamicUsage(__buildDynamicUsage);
+
// Mark end of compile phase: route matching, middleware, tree building are done.
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
@@ -16052,8 +16481,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If we clear context now, headers()/cookies() will fail during rendering.
// Context will be cleared when the next request starts (via runWithRequestContext).
const __dynamicUsedInRsc = consumeDynamicUsage();
+ const __rscResponseRenderHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
+ const __rscLateDynamicUsage = __isrRscDataPromise ? peekDynamicUsage() : false;
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
isDynamicError,
isForceDynamic,
isForceStatic,
@@ -16064,6 +16497,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
middlewareContext: _mwCtx,
params,
policy: __rscResponsePolicy,
+ renderHeaders: __rscResponseRenderHeaders,
timing: process.env.NODE_ENV !== "production"
? {
compileEnd: __compileEnd,
@@ -16079,10 +16513,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
cleanPathname,
consumeDynamicUsage,
- dynamicUsedDuringBuild: __dynamicUsedInRsc,
+ consumeRenderResponseHeaders,
+ dynamicUsedDuringBuild: __dynamicUsedInRsc || __rscLateDynamicUsage,
getPageTags() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: __rscResponseRenderHeaders,
isrDebug: __isrDebug,
isrRscKey: __isrRscKey,
isrSet: __isrSet,
@@ -16129,7 +16565,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (specialResponse) return specialResponse;
// Non-special error during SSR — render error.tsx if available
const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params);
- if (errorBoundaryResp) return errorBoundaryResp;
+ if (errorBoundaryResp) {
+ return __responseWithMiddlewareContext(
+ errorBoundaryResp,
+ _mwCtx,
+ consumeRenderResponseHeaders(),
+ { applyRewriteStatus: false },
+ );
+ }
throw ssrErr;
}
@@ -16139,15 +16582,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// when the error falls through to global-error.tsx.
- // Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
- const draftCookie = getDraftModeCookieHeader();
+ const renderResponseHeaders = __isrRscDataPromise
+ ? peekRenderResponseHeaders()
+ : consumeRenderResponseHeaders();
setHeadersContext(null);
setNavigationContext(null);
// Check if any component called connection(), cookies(), headers(), or noStore()
// during rendering. If so, treat as dynamic (skip ISR, set no-store).
- const dynamicUsedDuringRender = consumeDynamicUsage();
+ const dynamicUsedDuringRender = __isrRscDataPromise
+ ? peekDynamicUsage()
+ : consumeDynamicUsage();
// Check if cacheLife() was called during rendering (e.g., page with file-level "use cache").
// If so, use its revalidation period for the Cache-Control header.
@@ -16177,18 +16623,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// static here so we emit s-maxage=31536000 but skip ISR cache management.
if (__htmlResponsePolicy.shouldWriteToCache) {
const __isrResponseProd = __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
return __finalizeAppPageHtmlCacheResponse(__isrResponseProd, {
capturedRscDataPromise: __isrRscDataPromise,
cleanPathname,
+ consumeDynamicUsage,
+ consumeRenderResponseHeaders,
getPageTags: function() {
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
},
+ initialRenderHeaders: renderResponseHeaders,
isrDebug: __isrDebug,
isrHtmlKey: __isrHtmlKey,
isrRscKey: __isrRscKey,
@@ -16203,10 +16652,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
return __buildAppPageHtmlResponse(htmlStream, {
- draftCookie,
fontLinkHeader,
middlewareContext: _mwCtx,
policy: __htmlResponsePolicy,
+ renderHeaders: renderResponseHeaders,
timing: __htmlResponseTiming,
});
}
diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts
index 7746465b9..81b9647eb 100644
--- a/tests/app-page-cache.test.ts
+++ b/tests/app-page-cache.test.ts
@@ -22,12 +22,13 @@ function buildCachedAppPageValue(
html: string,
rscData?: ArrayBuffer,
status?: number,
+ headers?: Record,
): CachedAppPageValue {
return {
kind: "APP_PAGE",
html,
rscData,
- headers: undefined,
+ headers,
postponed: undefined,
status,
};
@@ -36,7 +37,11 @@ function buildCachedAppPageValue(
describe("app page cache helpers", () => {
it("builds cached HTML and RSC responses", async () => {
const rscData = new TextEncoder().encode("flight").buffer;
- const cachedValue = buildCachedAppPageValue("cached
", rscData, 201);
+ const cachedValue = buildCachedAppPageValue("cached
", rscData, 201, {
+ "set-cookie": "rendered=1; Path=/",
+ vary: "x-render",
+ "x-rendered": "yes",
+ });
const htmlResponse = buildAppPageCachedResponse(cachedValue, {
cacheState: "HIT",
@@ -46,6 +51,9 @@ describe("app page cache helpers", () => {
expect(htmlResponse?.status).toBe(201);
expect(htmlResponse?.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(htmlResponse?.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(htmlResponse?.headers.get("vary")).toBe("x-render, RSC, Accept");
+ expect(htmlResponse?.headers.get("x-rendered")).toBe("yes");
+ expect(htmlResponse?.headers.getSetCookie?.()).toEqual(["rendered=1; Path=/"]);
await expect(htmlResponse?.text()).resolves.toBe("cached
");
const rscResponse = buildAppPageCachedResponse(cachedValue, {
@@ -127,6 +135,7 @@ describe("app page cache helpers", () => {
it("serves stale entries and regenerates HTML and RSC cache keys", async () => {
const scheduledRegenerations: Array<() => Promise> = [];
const isrSetCalls: Array<{
+ headers: Record | undefined;
key: string;
html: string;
hasRscData: boolean;
@@ -150,6 +159,7 @@ describe("app page cache helpers", () => {
},
async isrSet(key, data, revalidateSeconds, tags) {
isrSetCalls.push({
+ headers: data.headers,
key,
html: data.html,
hasRscData: Boolean(data.rscData),
@@ -160,6 +170,10 @@ describe("app page cache helpers", () => {
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ },
html: "fresh
",
rscData,
tags: ["/stale", "_N_T_/stale"],
@@ -177,6 +191,10 @@ describe("app page cache helpers", () => {
expect(isrSetCalls).toEqual([
{
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ },
key: "html:/stale",
html: "fresh
",
hasRscData: false,
@@ -184,6 +202,10 @@ describe("app page cache helpers", () => {
tags: ["/stale", "_N_T_/stale"],
},
{
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ },
key: "rsc:/stale",
html: "",
hasRscData: true,
@@ -222,6 +244,7 @@ describe("app page cache helpers", () => {
revalidateSeconds: 60,
async renderFreshPageForCache() {
return {
+ headers: undefined,
html: "fresh
",
rscData: new TextEncoder().encode("fresh-flight").buffer,
tags: ["/stale-html-miss", "_N_T_/stale-html-miss"],
@@ -271,6 +294,7 @@ describe("app page cache helpers", () => {
it("finalizes HTML responses by teeing the stream and writing HTML and RSC cache keys", async () => {
const pendingCacheWrites: Promise[] = [];
const isrSetCalls: Array<{
+ headers: Record | undefined;
key: string;
html: string;
hasRscData: boolean;
@@ -292,9 +316,21 @@ describe("app page cache helpers", () => {
{
capturedRscDataPromise: Promise.resolve(rscData),
cleanPathname: "/fresh",
+ consumeDynamicUsage() {
+ return false;
+ },
+ consumeRenderResponseHeaders() {
+ return {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ };
+ },
getPageTags() {
return ["/fresh", "_N_T_/fresh"];
},
+ initialRenderHeaders: {
+ "set-cookie": "fallback=1; Path=/",
+ },
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
@@ -306,6 +342,7 @@ describe("app page cache helpers", () => {
},
async isrSet(key, data, revalidateSeconds, tags) {
isrSetCalls.push({
+ headers: data.headers,
key,
html: data.html,
hasRscData: Boolean(data.rscData),
@@ -328,6 +365,10 @@ describe("app page cache helpers", () => {
expect(isrSetCalls).toEqual([
{
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ },
key: "html:/fresh",
html: "fresh
",
hasRscData: false,
@@ -335,6 +376,10 @@ describe("app page cache helpers", () => {
tags: ["/fresh", "_N_T_/fresh"],
},
{
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ "x-rendered": "yes",
+ },
key: "rsc:/fresh",
html: "",
hasRscData: true,
@@ -349,6 +394,7 @@ describe("app page cache helpers", () => {
const pendingCacheWrites: Promise[] = [];
const debugCalls: Array<[string, string]> = [];
const isrSetCalls: Array<{
+ headers: Record | undefined;
key: string;
html: string;
hasRscData: boolean;
@@ -362,10 +408,18 @@ describe("app page cache helpers", () => {
consumeDynamicUsage() {
return false;
},
+ consumeRenderResponseHeaders() {
+ return {
+ "set-cookie": "rendered=1; Path=/",
+ };
+ },
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/fresh-rsc", "_N_T_/fresh-rsc"];
},
+ initialRenderHeaders: {
+ "set-cookie": "fallback=1; Path=/",
+ },
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
@@ -374,6 +428,7 @@ describe("app page cache helpers", () => {
},
async isrSet(key, data, revalidateSeconds, tags) {
isrSetCalls.push({
+ headers: data.headers,
key,
html: data.html,
hasRscData: Boolean(data.rscData),
@@ -394,6 +449,9 @@ describe("app page cache helpers", () => {
expect(isrSetCalls).toEqual([
{
+ headers: {
+ "set-cookie": "rendered=1; Path=/",
+ },
key: "rsc:/fresh-rsc",
html: "",
hasRscData: true,
@@ -415,10 +473,18 @@ describe("app page cache helpers", () => {
consumeDynamicUsage() {
return true;
},
+ consumeRenderResponseHeaders() {
+ return {
+ "set-cookie": "rendered=1; Path=/",
+ };
+ },
dynamicUsedDuringBuild: false,
getPageTags() {
return ["/dynamic-rsc", "_N_T_/dynamic-rsc"];
},
+ initialRenderHeaders: {
+ "set-cookie": "fallback=1; Path=/",
+ },
isrDebug(event, detail) {
debugCalls.push([event, detail]);
},
diff --git a/tests/app-page-response.test.ts b/tests/app-page-response.test.ts
index 1a679968f..22dd0bc41 100644
--- a/tests/app-page-response.test.ts
+++ b/tests/app-page-response.test.ts
@@ -211,6 +211,11 @@ describe("app page response helpers", () => {
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
},
+ renderHeaders: {
+ "set-cookie": "rendered=1; Path=/",
+ vary: "x-render",
+ "x-rendered": "yes",
+ },
timing: {
compileEnd: 15,
handlerStart: 10,
@@ -223,18 +228,22 @@ describe("app page response helpers", () => {
expect(response.headers.get("x-vinext-params")).toBe('{"slug":"test"}');
expect(response.headers.get("cache-control")).toBe("private, max-age=5");
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
- expect(response.headers.get("vary")).toBe("RSC, Accept, Next-Router-State-Tree");
+ expect(response.headers.get("vary")).toBe("x-render, RSC, Accept, Next-Router-State-Tree");
+ expect(response.headers.get("x-rendered")).toBe("yes");
+ expect(response.headers.getSetCookie?.()).toEqual([
+ "rendered=1; Path=/",
+ "session=abc; Path=/",
+ ]);
expect(response.headers.get("x-vinext-timing")).toBe("10,5,-1");
await expect(response.text()).resolves.toBe("flight");
});
- it("builds HTML responses with draft cookies, preload links, middleware, and timing", async () => {
+ it("builds HTML responses with render headers, preload links, middleware, and timing", async () => {
const middlewareHeaders = new Headers();
middlewareHeaders.append("set-cookie", "mw=1; Path=/");
middlewareHeaders.append("x-extra", "present");
const response = buildAppPageHtmlResponse(createBody("page
"), {
- draftCookie: "__prerender_bypass=token; Path=/",
fontLinkHeader: "; rel=preload; as=font; type=font/woff2; crossorigin",
middlewareContext: {
headers: middlewareHeaders,
@@ -244,6 +253,11 @@ describe("app page response helpers", () => {
cacheControl: "s-maxage=31536000, stale-while-revalidate",
cacheState: "STATIC",
},
+ renderHeaders: {
+ "set-cookie": "rendered=1; Path=/",
+ vary: "x-render",
+ "x-rendered": "yes",
+ },
timing: {
compileEnd: 12,
handlerStart: 10,
@@ -259,11 +273,13 @@ describe("app page response helpers", () => {
expect(response.headers.get("link")).toBe(
"; rel=preload; as=font; type=font/woff2; crossorigin",
);
+ expect(response.headers.get("vary")).toBe("x-render, RSC, Accept");
+ expect(response.headers.get("x-rendered")).toBe("yes");
expect(response.headers.get("x-extra")).toBe("present");
expect(response.headers.get("x-vinext-timing")).toBe("10,2,8");
const setCookies = response.headers.getSetCookie();
- expect(setCookies).toContain("__prerender_bypass=token; Path=/");
+ expect(setCookies).toContain("rendered=1; Path=/");
expect(setCookies).toContain("mw=1; Path=/");
await expect(response.text()).resolves.toBe("page
");
});
diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts
index 0b832aa37..70a0843dc 100644
--- a/tests/app-route-handler-execution.test.ts
+++ b/tests/app-route-handler-execution.test.ts
@@ -69,20 +69,19 @@ describe("app route handler execution helpers", () => {
didClearRequestContext = true;
},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
+ consumeRenderResponseHeaders() {
+ return {
+ "set-cookie": ["session=1; Path=/", "draft=1; Path=/"],
+ };
+ },
executionContext: {
waitUntil(promise) {
waitUntilPromises.push(promise);
},
},
- getAndClearPendingCookies() {
- return ["session=1; Path=/"];
- },
getCollectedFetchTags() {
return ["tag:demo"];
},
- getDraftModeCookieHeader() {
- return "draft=1; Path=/";
- },
handler: { dynamic: "auto" },
handlerFn() {
return new Response("ok", {
@@ -153,16 +152,13 @@ describe("app route handler execution helpers", () => {
cleanPathname: "/api/dynamic",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
- executionContext: null,
- getAndClearPendingCookies() {
- return [];
+ consumeRenderResponseHeaders() {
+ return undefined;
},
+ executionContext: null,
getCollectedFetchTags() {
return [];
},
- getDraftModeCookieHeader() {
- return null;
- },
handler: { dynamic: "auto" },
handlerFn(request) {
return Response.json({
@@ -211,16 +207,15 @@ describe("app route handler execution helpers", () => {
cleanPathname: "/api/redirect",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
- executionContext: null,
- getAndClearPendingCookies() {
- return [];
+ consumeRenderResponseHeaders() {
+ return {
+ "set-cookie": "route-special=redirect; Path=/; HttpOnly",
+ };
},
+ executionContext: null,
getCollectedFetchTags() {
return [];
},
- getDraftModeCookieHeader() {
- return null;
- },
handler: { dynamic: "auto" },
handlerFn() {
throw { digest: "NEXT_REDIRECT;replace;%2Ftarget;308" };
@@ -233,7 +228,10 @@ describe("app route handler execution helpers", () => {
async isrSet() {},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
- middlewareContext: { headers: null, status: null },
+ middlewareContext: {
+ headers: new Headers([["x-middleware", "present"]]),
+ status: 403,
+ },
params: {},
reportRequestError(error) {
reportedErrors.push(error);
@@ -248,6 +246,10 @@ describe("app route handler execution helpers", () => {
expect(redirectResponse.status).toBe(308);
expect(redirectResponse.headers.get("location")).toBe("https://example.com/target");
+ expect(redirectResponse.headers.getSetCookie?.()).toEqual([
+ "route-special=redirect; Path=/; HttpOnly",
+ ]);
+ expect(redirectResponse.headers.get("x-middleware")).toBe("present");
expect(reportedErrors).toEqual([]);
const errorResponse = await executeAppRouteHandler({
@@ -257,16 +259,13 @@ describe("app route handler execution helpers", () => {
cleanPathname: "/api/error",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
- executionContext: null,
- getAndClearPendingCookies() {
- return [];
+ consumeRenderResponseHeaders() {
+ return undefined;
},
+ executionContext: null,
getCollectedFetchTags() {
return [];
},
- getDraftModeCookieHeader() {
- return null;
- },
handler: { dynamic: "auto" },
handlerFn() {
throw new Error("boom");
diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts
index 9b7423957..d63f23c17 100644
--- a/tests/app-route-handler-response.test.ts
+++ b/tests/app-route-handler-response.test.ts
@@ -52,11 +52,46 @@ describe("app route handler response helpers", () => {
expect(result.status).toBe(202);
expect(result.headers.get("content-type")).toBe("text/plain");
- expect(result.headers.get("x-response")).toBe("app, middleware-copy");
+ expect(result.headers.get("x-response")).toBe("middleware-copy");
expect(result.headers.get("x-middleware")).toBe("mw");
await expect(result.text()).resolves.toBe("hello");
});
+ it("drops stale statusText when middleware overrides the route-handler status", () => {
+ const response = new Response("hello", {
+ status: 201,
+ statusText: "Created",
+ });
+
+ const result = applyRouteHandlerMiddlewareContext(response, {
+ headers: null,
+ status: 403,
+ });
+
+ expect(result.status).toBe(403);
+ expect(result.statusText).not.toBe("Created");
+ });
+
+ it("can preserve special-response status codes when rewrite status must not apply", () => {
+ const response = new Response(null, {
+ status: 307,
+ headers: { Location: "https://example.com/about" },
+ });
+
+ const result = applyRouteHandlerMiddlewareContext(
+ response,
+ {
+ headers: new Headers([["x-middleware", "present"]]),
+ status: 403,
+ },
+ { applyRewriteStatus: false },
+ );
+
+ expect(result.status).toBe(307);
+ expect(result.headers.get("location")).toBe("https://example.com/about");
+ expect(result.headers.get("x-middleware")).toBe("present");
+ });
+
it("builds cached HIT and STALE route handler responses", async () => {
const cachedValue = buildCachedRouteValue("from-cache", {
"content-type": "text/plain",
@@ -103,7 +138,7 @@ describe("app route handler response helpers", () => {
expect(new TextDecoder().decode(value.body)).toBe("cache me");
});
- it("finalizes route handler responses with cookies and auto-head semantics", async () => {
+ it("finalizes route handler responses with render headers and auto-head semantics", async () => {
const response = new Response("body", {
status: 202,
statusText: "Accepted",
@@ -113,14 +148,17 @@ describe("app route handler response helpers", () => {
});
const result = finalizeRouteHandlerResponse(response, {
- pendingCookies: ["a=1; Path=/"],
- draftCookie: "draft=1; Path=/",
isHead: true,
+ renderHeaders: {
+ "set-cookie": ["a=1; Path=/", "draft=1; Path=/"],
+ vary: "x-test",
+ },
});
expect(result.status).toBe(202);
expect(result.statusText).toBe("Accepted");
expect(result.headers.getSetCookie?.()).toEqual(["a=1; Path=/", "draft=1; Path=/"]);
+ expect(result.headers.get("vary")).toBe("x-test");
await expect(result.text()).resolves.toBe("");
});
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index 014247929..871fd3ef4 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -559,6 +559,28 @@ describe("App Router integration", () => {
expect(html).toContain('');
});
+ it("replays render-time headers and middleware context for layout-level notFound()", async () => {
+ const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-layout-notfound/missing`);
+ expect(res.status).toBe(404);
+ expect(res.headers.get("x-layout-notfound")).toBe("yes");
+ expect(res.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(res.headers.get("x-mw-ran")).toBe("true");
+ expect(res.headers.get("x-mw-pathname")).toBe(
+ "/nextjs-compat/render-headers-layout-notfound/missing",
+ );
+ expect(res.headers.getSetCookie()).toEqual([
+ "layout-notfound=1; Path=/; HttpOnly",
+ "middleware-render=1; Path=/; HttpOnly",
+ ]);
+ expect(res.headers.get("vary")).toContain("RSC");
+ expect(res.headers.get("vary")).toContain("Accept");
+ expect(res.headers.get("vary")).toContain("x-middleware-test");
+
+ const html = await res.text();
+ expect(html).toContain("404 - Page Not Found");
+ expect(html).toContain("does not exist");
+ });
+
it("forbidden() from Server Component returns 403 with forbidden.tsx", async () => {
const res = await fetch(`${baseUrl}/forbidden-test`);
expect(res.status).toBe(403);
@@ -592,6 +614,96 @@ describe("App Router integration", () => {
expect(location).toContain("/about");
});
+ it("middleware rewrite status does not override redirect() or notFound() responses", async () => {
+ const redirectRes = await fetch(`${baseUrl}/middleware-rewrite-status-redirect`, {
+ redirect: "manual",
+ });
+ expect(redirectRes.status).toBe(307);
+ expect(redirectRes.headers.get("location")).toContain("/about");
+
+ const notFoundRes = await fetch(`${baseUrl}/middleware-rewrite-status-not-found`);
+ expect(notFoundRes.status).toBe(404);
+
+ const html = await notFoundRes.text();
+ expect(html).toContain("404 - Page Not Found");
+ expect(html).toContain("does not exist");
+ });
+
+ it("middleware rewrite status does not reuse a route handler's stale statusText", async () => {
+ const directRes = await fetch(`${baseUrl}/api/custom-status-text`);
+ expect(directRes.status).toBe(201);
+ expect(directRes.statusText).toBe("Created");
+ expect(await directRes.text()).toBe("custom status text route");
+
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-status-text`);
+ expect(res.status).toBe(403);
+ expect(res.statusText).not.toBe("Created");
+ expect(await res.text()).toBe("custom status text route");
+ });
+
+ it("middleware rewrite status does not override route-handler redirects with queued cookies", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-redirect-with-cookie`, {
+ redirect: "manual",
+ });
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("/about");
+ expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("middleware rewrite status does not override route-handler notFound() with queued cookies", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-not-found-with-cookie`);
+ expect(res.status).toBe(404);
+ expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("middleware rewrite status does not override route-handler unauthorized() with queued cookies", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-unauthorized-with-cookie`);
+ expect(res.status).toBe(401);
+ expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("preserves render-time headers and middleware context for redirects thrown during metadata resolution", async () => {
+ const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-redirect`, {
+ redirect: "manual",
+ });
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("/about");
+ expect(res.headers.get("x-rendered-in-metadata")).toBe("yes");
+ expect(res.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(res.headers.get("x-mw-ran")).toBe("true");
+ expect(res.headers.get("x-mw-pathname")).toBe(
+ "/nextjs-compat/render-headers-metadata-redirect",
+ );
+ expect(res.headers.getSetCookie()).toEqual([
+ "metadata-redirect=1; Path=/; HttpOnly",
+ "middleware-render=1; Path=/; HttpOnly",
+ ]);
+ expect(res.headers.get("vary")).toBe("x-middleware-test");
+ });
+
+ it("preserves render-time headers set during generateMetadata on normal renders", async () => {
+ const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-normal`);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("x-metadata-normal")).toBe("yes");
+ expect(res.headers.getSetCookie()).toContain("metadata-normal=1; Path=/; HttpOnly");
+ const html = await res.text();
+ expect(html).toContain("metadata normal route");
+ });
+
+ it("preserves build-time render headers on direct non-ISR RSC responses", async () => {
+ const res = await fetch(`${baseUrl}/nextjs-compat/render-headers-metadata-normal.rsc`, {
+ headers: { Accept: "text/x-component" },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("x-metadata-normal")).toBe("yes");
+ expect(res.headers.getSetCookie()).toContain("metadata-normal=1; Path=/; HttpOnly");
+ expect((await res.text()).length).toBeGreaterThan(0);
+ });
+
it("permanentRedirect() returns 308 status code", async () => {
const res = await fetch(`${baseUrl}/permanent-redirect-test`, { redirect: "manual" });
expect(res.status).toBe(308);
@@ -1475,6 +1587,36 @@ describe("App Router Production server (startProdServer)", () => {
);
}
+ async function sleep(ms: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ async function fetchUntilCacheState(
+ pathname: string,
+ expectedState: string,
+ init?: RequestInit,
+ timeoutMs: number = 3000,
+ ): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let lastState: string | null = null;
+
+ while (Date.now() < deadline) {
+ const res = await fetch(`${baseUrl}${pathname}`, init);
+ const cacheState = res.headers.get("x-vinext-cache");
+ if (cacheState === expectedState) {
+ return res;
+ }
+
+ lastState = cacheState;
+ await res.arrayBuffer();
+ await sleep(25);
+ }
+
+ throw new Error(
+ `Timed out waiting for ${pathname} to reach cache state ${expectedState}; last state was ${lastState}`,
+ );
+ }
+
beforeAll(async () => {
// Build the app-basic fixture to the default dist/ directory
const builder = await createBuilder({
@@ -1636,6 +1778,287 @@ describe("App Router Production server (startProdServer)", () => {
expect(body).toContain("Bad Request");
});
+ it("replays render-time response headers across HTML MISS/HIT/STALE and regeneration", async () => {
+ const expectedVaryPrefix = [
+ "x-render-one",
+ "x-render-two",
+ "RSC",
+ "Accept",
+ "x-middleware-test",
+ ];
+ const expectedWwwAuthenticate =
+ 'Basic realm="render", Bearer realm="render", Digest realm="middleware"';
+ const expectMergedVary = (value: string | null) => {
+ expect(value).not.toBeNull();
+ const tokens = value!.split(",").map((part) => part.trim());
+ expect(tokens.slice(0, expectedVaryPrefix.length)).toEqual(expectedVaryPrefix);
+ };
+ const expectedMissSetCookies = [
+ "rendered=1; Path=/; HttpOnly",
+ "rendered-second=1; Path=/; HttpOnly",
+ "middleware-render=1; Path=/; HttpOnly",
+ "middleware-render-second=1; Path=/; HttpOnly",
+ ];
+ const expectedCachedSetCookies = [
+ "rendered=1; Path=/; HttpOnly",
+ "rendered-second=1; Path=/; HttpOnly",
+ "rendered-late=1; Path=/; HttpOnly",
+ "middleware-render=1; Path=/; HttpOnly",
+ "middleware-render-second=1; Path=/; HttpOnly",
+ ];
+
+ const res1 = await fetch(`${baseUrl}/nextjs-compat/cached-render-headers`);
+ expect(res1.status).toBe(200);
+ expect(res1.headers.get("x-vinext-cache")).toBe("MISS");
+ expect(res1.headers.get("x-rendered-in-page")).toBe("yes");
+ expect(res1.headers.get("x-rendered-late")).toBeNull();
+ expect(res1.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(res1.headers.get("x-mw-ran")).toBe("true");
+ expect(res1.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers");
+ expectMergedVary(res1.headers.get("vary"));
+ expect(res1.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate);
+ expect(res1.headers.getSetCookie()).toEqual(expectedMissSetCookies);
+
+ const res2 = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT");
+ expect(res2.status).toBe(200);
+ expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(res2.headers.get("x-rendered-in-page")).toBe("yes");
+ expect(res2.headers.get("x-rendered-late")).toBe("yes");
+ expect(res2.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(res2.headers.get("x-mw-ran")).toBe("true");
+ expect(res2.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers");
+ expectMergedVary(res2.headers.get("vary"));
+ expect(res2.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate);
+ expect(res2.headers.getSetCookie()).toEqual(expectedCachedSetCookies);
+
+ await sleep(1100);
+
+ const staleRes = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "STALE");
+ expect(staleRes.status).toBe(200);
+ expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE");
+ expect(staleRes.headers.get("x-rendered-in-page")).toBe("yes");
+ expect(staleRes.headers.get("x-rendered-late")).toBe("yes");
+ expect(staleRes.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(staleRes.headers.get("x-mw-ran")).toBe("true");
+ expect(staleRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers");
+ expectMergedVary(staleRes.headers.get("vary"));
+ expect(staleRes.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate);
+ expect(staleRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies);
+
+ const regenHitRes = await fetchUntilCacheState("/nextjs-compat/cached-render-headers", "HIT");
+ expect(regenHitRes.status).toBe(200);
+ expect(regenHitRes.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(regenHitRes.headers.get("x-rendered-in-page")).toBe("yes");
+ expect(regenHitRes.headers.get("x-rendered-late")).toBe("yes");
+ expect(regenHitRes.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(regenHitRes.headers.get("x-mw-ran")).toBe("true");
+ expect(regenHitRes.headers.get("x-mw-pathname")).toBe("/nextjs-compat/cached-render-headers");
+ expectMergedVary(regenHitRes.headers.get("vary"));
+ expect(regenHitRes.headers.get("www-authenticate")).toBe(expectedWwwAuthenticate);
+ expect(regenHitRes.headers.getSetCookie()).toEqual(expectedCachedSetCookies);
+ });
+
+ it("streams HTML ISR MISSes without waiting for full RSC capture", async () => {
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const streamServer = await startProdServer({ port: 0, outDir, noCompression: true });
+ try {
+ const streamBaseUrl = `http://localhost:${streamServer.port}`;
+
+ const start = performance.now();
+ const res = await fetch(`${streamBaseUrl}/nextjs-compat/cached-render-headers-stream`);
+ const fetchResolvedAt = performance.now();
+ const reader = res.body?.getReader();
+ expect(reader).toBeDefined();
+
+ const first = await reader!.read();
+ expect(first.done).toBe(false);
+ for (;;) {
+ const chunk = await reader!.read();
+ if (chunk.done) break;
+ }
+ const doneAt = performance.now();
+
+ expect(res.headers.get("x-vinext-cache")).toBe("MISS");
+ expect(fetchResolvedAt - start).toBeLessThan(doneAt - start);
+ } finally {
+ await new Promise((resolve) => streamServer.server.close(() => resolve()));
+ }
+ });
+
+ it("streams direct RSC ISR MISSes without waiting for the full RSC payload", async () => {
+ const expectedMiddlewareSetCookies = [
+ "middleware-render=1; Path=/; HttpOnly",
+ "middleware-render-second=1; Path=/; HttpOnly",
+ ];
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const streamServer = await startProdServer({ port: 0, outDir, noCompression: true });
+ try {
+ const streamBaseUrl = `http://localhost:${streamServer.port}`;
+
+ const start = performance.now();
+ const res = await fetch(
+ `${streamBaseUrl}/nextjs-compat/cached-render-headers-rsc-first.rsc`,
+ {
+ headers: { Accept: "text/x-component" },
+ },
+ );
+ const fetchResolvedAt = performance.now();
+ const reader = res.body?.getReader();
+ expect(reader).toBeDefined();
+
+ const first = await reader!.read();
+ const firstAt = performance.now();
+ expect(first.done).toBe(false);
+ for (;;) {
+ const chunk = await reader!.read();
+ if (chunk.done) break;
+ }
+ const doneAt = performance.now();
+
+ expect(res.headers.get("x-vinext-cache")).toBe("MISS");
+ expect(res.headers.get("x-rendered-late")).toBeNull();
+ expect(res.headers.getSetCookie()).toEqual(
+ expect.arrayContaining(expectedMiddlewareSetCookies),
+ );
+ expect(res.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly");
+ expect(fetchResolvedAt - start).toBeLessThan(doneAt - start);
+ expect(doneAt - firstAt).toBeGreaterThan(15);
+ } finally {
+ await new Promise((resolve) => streamServer.server.close(() => resolve()));
+ }
+ });
+
+ it("replays final render-time headers on direct RSC HITs after streamed MISSes", async () => {
+ const expectedMissVary = "RSC, Accept, x-middleware-test";
+ const expectedHitVary = "x-render-one, x-render-two, RSC, Accept, x-middleware-test";
+ const expectedMissWwwAuthenticate = 'Digest realm="middleware"';
+ const expectedHitWwwAuthenticate =
+ 'Basic realm="render", Bearer realm="render", Digest realm="middleware"';
+ const expectedMiddlewareSetCookies = [
+ "middleware-render=1; Path=/; HttpOnly",
+ "middleware-render-second=1; Path=/; HttpOnly",
+ ];
+ const expectedHitSetCookies = [
+ "rendered=1; Path=/; HttpOnly",
+ "rendered-second=1; Path=/; HttpOnly",
+ "rendered-late=1; Path=/; HttpOnly",
+ "middleware-render=1; Path=/; HttpOnly",
+ "middleware-render-second=1; Path=/; HttpOnly",
+ ];
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const rscServer = await startProdServer({ port: 0, outDir, noCompression: true });
+
+ const fetchUntilLocalCacheState = async (
+ localBaseUrl: string,
+ pathname: string,
+ expectedState: string,
+ init?: RequestInit,
+ timeoutMs: number = 3000,
+ ): Promise => {
+ const deadline = Date.now() + timeoutMs;
+ let lastState: string | null = null;
+
+ while (Date.now() < deadline) {
+ const res = await fetch(`${localBaseUrl}${pathname}`, init);
+ const cacheState = res.headers.get("x-vinext-cache");
+ if (cacheState === expectedState) {
+ return res;
+ }
+
+ lastState = cacheState;
+ await res.arrayBuffer();
+ await sleep(25);
+ }
+
+ throw new Error(
+ `Timed out waiting for ${pathname} to reach cache state ${expectedState}; last state was ${lastState}`,
+ );
+ };
+
+ try {
+ const localBaseUrl = `http://localhost:${rscServer.port}`;
+
+ const missRes = await fetch(
+ `${localBaseUrl}/nextjs-compat/cached-render-headers-rsc-parity.rsc`,
+ {
+ headers: { Accept: "text/x-component" },
+ },
+ );
+ expect(missRes.status).toBe(200);
+ expect(missRes.headers.get("x-vinext-cache")).toBe("MISS");
+ expect(missRes.headers.get("x-rendered-in-page")).toBeNull();
+ expect(missRes.headers.get("x-rendered-late")).toBeNull();
+ expect(missRes.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(missRes.headers.get("x-mw-ran")).toBe("true");
+ expect(missRes.headers.get("vary")).toBe(expectedMissVary);
+ expect(missRes.headers.get("www-authenticate")).toBe(expectedMissWwwAuthenticate);
+ expect(missRes.headers.getSetCookie()).toEqual(
+ expect.arrayContaining(expectedMiddlewareSetCookies),
+ );
+ expect(missRes.headers.getSetCookie()).not.toContain("rendered-late=1; Path=/; HttpOnly");
+ const missBody = await missRes.text();
+ expect(missBody.length).toBeGreaterThan(0);
+
+ const hitRes = await fetchUntilLocalCacheState(
+ localBaseUrl,
+ "/nextjs-compat/cached-render-headers-rsc-parity.rsc",
+ "HIT",
+ {
+ headers: { Accept: "text/x-component" },
+ },
+ );
+ expect(hitRes.status).toBe(200);
+ expect(hitRes.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(hitRes.headers.get("x-rendered-in-page")).toBe("yes");
+ expect(hitRes.headers.get("x-rendered-late")).toBe("yes");
+ expect(hitRes.headers.get("x-mw-conflict")).toBe("middleware");
+ expect(hitRes.headers.get("x-mw-ran")).toBe("true");
+ expect(hitRes.headers.get("vary")).toBe(expectedHitVary);
+ expect(hitRes.headers.get("www-authenticate")).toBe(expectedHitWwwAuthenticate);
+ expect(hitRes.headers.getSetCookie()).toEqual(expectedHitSetCookies);
+ const hitBody = await hitRes.text();
+ expect(hitBody.length).toBeGreaterThan(0);
+ } finally {
+ await new Promise((resolve) => rscServer.server.close(() => resolve()));
+ }
+ });
+
+ it("middleware rewrite status does not reuse a route handler's stale statusText in production", async () => {
+ const directRes = await fetch(`${baseUrl}/api/custom-status-text`);
+ expect(directRes.status).toBe(201);
+ expect(directRes.statusText).toBe("Created");
+ expect(await directRes.text()).toBe("custom status text route");
+
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-status-text`);
+ expect(res.status).toBe(403);
+ expect(res.statusText).not.toBe("Created");
+ expect(await res.text()).toBe("custom status text route");
+ });
+
+ it("middleware rewrite status does not override route-handler redirects with queued cookies in production", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-redirect-with-cookie`, {
+ redirect: "manual",
+ });
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("/about");
+ expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("middleware rewrite status does not override route-handler notFound() with queued cookies in production", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-not-found-with-cookie`);
+ expect(res.status).toBe(404);
+ expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("middleware rewrite status does not override route-handler unauthorized() with queued cookies in production", async () => {
+ const res = await fetch(`${baseUrl}/middleware-rewrite-status-route-unauthorized-with-cookie`);
+ expect(res.status).toBe(401);
+ expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
it("revalidateTag invalidates App Router ISR page entries by fetch tag", async () => {
const res1 = await fetch(`${baseUrl}/revalidate-tag-test`);
expect(res1.status).toBe(200);
@@ -3782,6 +4205,22 @@ describe("generateRscEntry ISR code generation", () => {
expect(code).toContain("consumeDynamicUsage,");
});
+ it("generated code imports render-header helpers from next/headers", () => {
+ const code = generateRscEntry("/tmp/test/app", minimalRoutes);
+ expect(code).toContain("peekRenderResponseHeaders");
+ expect(code).toContain("restoreRenderResponseHeaders");
+ expect(code).toContain("consumeRenderResponseHeaders");
+ });
+
+ it("generated code forwards render-header snapshots through page cache helpers", () => {
+ const code = generateRscEntry("/tmp/test/app", minimalRoutes);
+ expect(code).toContain("renderHeaders: __rscResponseRenderHeaders");
+ expect(code).toContain("consumeRenderResponseHeaders,");
+ expect(code).toContain("initialRenderHeaders: __rscResponseRenderHeaders");
+ expect(code).toContain("renderHeaders: renderResponseHeaders");
+ expect(code).toContain("initialRenderHeaders: renderResponseHeaders");
+ });
+
// Route handler ISR code generation tests
it("generated code contains __isrRouteKey helper", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
@@ -3804,7 +4243,10 @@ describe("generateRscEntry ISR code generation", () => {
// Route handler ISR writes now flow through the typed execution helper.
expect(code).toContain("executeAppRouteHandler as __executeAppRouteHandler");
expect(code).toContain("return __executeAppRouteHandler({");
+ expect(code).toContain("consumeRenderResponseHeaders,");
expect(code).toContain("isrRouteKey: __isrRouteKey");
expect(code).toContain("isrSet: __isrSet");
+ expect(code).not.toContain("getAndClearPendingCookies,");
+ expect(code).not.toContain("getDraftModeCookieHeader,");
});
});
diff --git a/tests/fixtures/app-basic/app/api/custom-status-text/route.ts b/tests/fixtures/app-basic/app/api/custom-status-text/route.ts
new file mode 100644
index 000000000..3eea40b11
--- /dev/null
+++ b/tests/fixtures/app-basic/app/api/custom-status-text/route.ts
@@ -0,0 +1,6 @@
+export async function GET() {
+ return new Response("custom status text route", {
+ status: 201,
+ statusText: "Created",
+ });
+}
diff --git a/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts
new file mode 100644
index 000000000..fdc2d6d57
--- /dev/null
+++ b/tests/fixtures/app-basic/app/api/not-found-with-cookie/route.ts
@@ -0,0 +1,8 @@
+import { cookies } from "next/headers";
+import { notFound } from "next/navigation";
+
+export async function GET() {
+ const cookieStore = await cookies();
+ cookieStore.set("route-special", "not-found", { path: "/", httpOnly: true });
+ notFound();
+}
diff --git a/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts
new file mode 100644
index 000000000..d095972e1
--- /dev/null
+++ b/tests/fixtures/app-basic/app/api/redirect-with-cookie/route.ts
@@ -0,0 +1,8 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+
+export async function GET() {
+ const cookieStore = await cookies();
+ cookieStore.set("route-special", "redirect", { path: "/", httpOnly: true });
+ redirect("/about");
+}
diff --git a/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts b/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts
new file mode 100644
index 000000000..966786de1
--- /dev/null
+++ b/tests/fixtures/app-basic/app/api/unauthorized-with-cookie/route.ts
@@ -0,0 +1,8 @@
+import { cookies } from "next/headers";
+import { unauthorized } from "next/navigation";
+
+export async function GET() {
+ const cookieStore = await cookies();
+ cookieStore.set("route-special", "unauthorized", { path: "/", httpOnly: true });
+ unauthorized();
+}
diff --git a/tests/fixtures/app-basic/app/lib/render-response-header.ts b/tests/fixtures/app-basic/app/lib/render-response-header.ts
new file mode 100644
index 000000000..f259cd399
--- /dev/null
+++ b/tests/fixtures/app-basic/app/lib/render-response-header.ts
@@ -0,0 +1 @@
+export { appendRenderResponseHeader } from "../../../../../packages/vinext/src/shims/headers.js";
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx
new file mode 100644
index 000000000..7c2f62c9b
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-first/page.tsx
@@ -0,0 +1 @@
+export { revalidate, default } from "../cached-render-headers/page";
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx
new file mode 100644
index 000000000..7c2f62c9b
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-rsc-parity/page.tsx
@@ -0,0 +1 @@
+export { revalidate, default } from "../cached-render-headers/page";
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx
new file mode 100644
index 000000000..7c2f62c9b
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers-stream/page.tsx
@@ -0,0 +1 @@
+export { revalidate, default } from "../cached-render-headers/page";
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx
new file mode 100644
index 000000000..5271ac5e4
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/cached-render-headers/page.tsx
@@ -0,0 +1,34 @@
+import { Suspense } from "react";
+import { appendRenderResponseHeader } from "../../lib/render-response-header";
+
+export const revalidate = 1;
+
+async function LateRenderHeaders() {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ appendRenderResponseHeader("x-rendered-late", "yes");
+ appendRenderResponseHeader("Set-Cookie", "rendered-late=1; Path=/; HttpOnly");
+ return RenderedLateHeader: yes
;
+}
+
+export default function CachedRenderHeadersPage() {
+ appendRenderResponseHeader("x-rendered-in-page", "yes");
+ appendRenderResponseHeader("x-mw-conflict", "page");
+ appendRenderResponseHeader("Vary", "x-render-one");
+ appendRenderResponseHeader("Vary", "x-render-two");
+ appendRenderResponseHeader("WWW-Authenticate", 'Basic realm="render"');
+ appendRenderResponseHeader("WWW-Authenticate", 'Bearer realm="render"');
+ appendRenderResponseHeader("Set-Cookie", "rendered=1; Path=/; HttpOnly");
+ appendRenderResponseHeader("Set-Cookie", "rendered-second=1; Path=/; HttpOnly");
+
+ return (
+
+ Cached Render Headers
+ RenderedHeader: yes
+ RenderedLateHeader: loading
}
+ >
+
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx
new file mode 100644
index 000000000..9420ae2dc
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/layout.tsx
@@ -0,0 +1,12 @@
+import { notFound } from "next/navigation";
+import { appendRenderResponseHeader } from "../../../lib/render-response-header";
+
+export default function RenderHeadersLayoutNotFoundLayout({ children, params }) {
+ appendRenderResponseHeader("x-layout-notfound", "yes");
+ appendRenderResponseHeader("x-mw-conflict", "layout");
+ appendRenderResponseHeader("Set-Cookie", "layout-notfound=1; Path=/; HttpOnly");
+ if (params.slug === "missing") {
+ notFound();
+ }
+ return ;
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx
new file mode 100644
index 000000000..359063b4f
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/[slug]/page.tsx
@@ -0,0 +1,3 @@
+export default function RenderHeadersLayoutNotFoundPage({ params }) {
+ return Slug: {params.slug}
;
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx
new file mode 100644
index 000000000..171fa5f4d
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-layout-notfound/not-found.tsx
@@ -0,0 +1,3 @@
+export default function RenderHeadersLayoutNotFoundBoundary() {
+ return Render headers layout not found boundary
;
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx
new file mode 100644
index 000000000..bc7b181fd
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-normal/page.tsx
@@ -0,0 +1,13 @@
+import { appendRenderResponseHeader } from "../../lib/render-response-header";
+
+export const revalidate = 1;
+
+export function generateMetadata() {
+ appendRenderResponseHeader("x-metadata-normal", "yes");
+ appendRenderResponseHeader("Set-Cookie", "metadata-normal=1; Path=/; HttpOnly");
+ return { title: "metadata-normal" };
+}
+
+export default function RenderHeadersMetadataNormalPage() {
+ return metadata normal route
;
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx
new file mode 100644
index 000000000..b4cf48ad4
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/render-headers-metadata-redirect/page.tsx
@@ -0,0 +1,13 @@
+import { redirect } from "next/navigation";
+import { appendRenderResponseHeader } from "../../lib/render-response-header";
+
+export function generateMetadata() {
+ appendRenderResponseHeader("x-rendered-in-metadata", "yes");
+ appendRenderResponseHeader("x-mw-conflict", "metadata");
+ appendRenderResponseHeader("Set-Cookie", "metadata-redirect=1; Path=/; HttpOnly");
+ redirect("/about");
+}
+
+export default function RenderHeadersMetadataRedirectPage() {
+ return unreachable
;
+}
diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts
index 818699e56..5b54366cb 100644
--- a/tests/fixtures/app-basic/middleware.ts
+++ b/tests/fixtures/app-basic/middleware.ts
@@ -27,6 +27,14 @@ export function middleware(request: NextRequest) {
const response = NextResponse.next();
+ const applyRenderHeaderParityHeaders = (target: NextResponse) => {
+ target.headers.set("x-mw-conflict", "middleware");
+ target.headers.set("Vary", "x-middleware-test");
+ target.headers.append("WWW-Authenticate", 'Digest realm="middleware"');
+ target.cookies.set("middleware-render", "1", { path: "/", httpOnly: true });
+ return target;
+ };
+
// Add headers to prove middleware ran and NextRequest APIs worked
response.headers.set("x-mw-pathname", pathname);
response.headers.set("x-mw-ran", "true");
@@ -35,6 +43,21 @@ export function middleware(request: NextRequest) {
response.headers.set("x-mw-has-session", "true");
}
+ if (
+ pathname === "/nextjs-compat/cached-render-headers" ||
+ pathname === "/nextjs-compat/cached-render-headers-stream" ||
+ pathname === "/nextjs-compat/cached-render-headers-rsc-first" ||
+ pathname === "/nextjs-compat/cached-render-headers-rsc-parity"
+ ) {
+ applyRenderHeaderParityHeaders(response);
+ response.cookies.set("middleware-render-second", "1", { path: "/", httpOnly: true });
+ } else if (
+ pathname === "/nextjs-compat/render-headers-metadata-redirect" ||
+ pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/")
+ ) {
+ applyRenderHeaderParityHeaders(response);
+ }
+
// Redirect /middleware-redirect to /about (with cookie, like OpenNext)
// Ref: opennextjs-cloudflare middleware.ts — redirect with set-cookie header
if (pathname === "/middleware-redirect") {
@@ -65,6 +88,42 @@ export function middleware(request: NextRequest) {
});
}
+ if (pathname === "/middleware-rewrite-status-redirect") {
+ return NextResponse.rewrite(new URL("/redirect-test", request.url), {
+ status: 403,
+ });
+ }
+
+ if (pathname === "/middleware-rewrite-status-not-found") {
+ return NextResponse.rewrite(new URL("/nextjs-compat/not-found-no-boundary/404", request.url), {
+ status: 403,
+ });
+ }
+
+ if (pathname === "/middleware-rewrite-status-route-status-text") {
+ return NextResponse.rewrite(new URL("/api/custom-status-text", request.url), {
+ status: 403,
+ });
+ }
+
+ if (pathname === "/middleware-rewrite-status-route-redirect-with-cookie") {
+ return NextResponse.rewrite(new URL("/api/redirect-with-cookie", request.url), {
+ status: 403,
+ });
+ }
+
+ if (pathname === "/middleware-rewrite-status-route-not-found-with-cookie") {
+ return NextResponse.rewrite(new URL("/api/not-found-with-cookie", request.url), {
+ status: 403,
+ });
+ }
+
+ if (pathname === "/middleware-rewrite-status-route-unauthorized-with-cookie") {
+ return NextResponse.rewrite(new URL("/api/unauthorized-with-cookie", request.url), {
+ status: 403,
+ });
+ }
+
// Block /middleware-blocked with custom response
if (pathname === "/middleware-blocked") {
return new Response("Blocked by middleware", { status: 403 });
@@ -149,16 +208,42 @@ export function middleware(request: NextRequest) {
if (sessionToken) {
r.headers.set("x-mw-has-session", "true");
}
+ if (
+ pathname === "/nextjs-compat/cached-render-headers" ||
+ pathname === "/nextjs-compat/cached-render-headers-stream" ||
+ pathname === "/nextjs-compat/cached-render-headers-rsc-first" ||
+ pathname === "/nextjs-compat/cached-render-headers-rsc-parity"
+ ) {
+ applyRenderHeaderParityHeaders(r);
+ r.cookies.set("middleware-render-second", "1", { path: "/", httpOnly: true });
+ } else if (
+ pathname === "/nextjs-compat/render-headers-metadata-redirect" ||
+ pathname.startsWith("/nextjs-compat/render-headers-layout-notfound/")
+ ) {
+ applyRenderHeaderParityHeaders(r);
+ }
return r;
}
export const config = {
matcher: [
"/about",
+ "/nextjs-compat/cached-render-headers",
+ "/nextjs-compat/cached-render-headers-stream",
+ "/nextjs-compat/cached-render-headers-rsc-first",
+ "/nextjs-compat/cached-render-headers-rsc-parity",
+ "/nextjs-compat/render-headers-metadata-redirect",
+ "/nextjs-compat/render-headers-layout-notfound/:path*",
"/middleware-redirect",
"/middleware-rewrite",
"/middleware-rewrite-query",
"/middleware-rewrite-status",
+ "/middleware-rewrite-status-redirect",
+ "/middleware-rewrite-status-not-found",
+ "/middleware-rewrite-status-route-status-text",
+ "/middleware-rewrite-status-route-redirect-with-cookie",
+ "/middleware-rewrite-status-route-not-found-with-cookie",
+ "/middleware-rewrite-status-route-unauthorized-with-cookie",
"/middleware-blocked",
"/middleware-throw",
"/search-query",
diff --git a/tests/image-optimization-parity.test.ts b/tests/image-optimization-parity.test.ts
index 505ce8e1d..10a947fae 100644
--- a/tests/image-optimization-parity.test.ts
+++ b/tests/image-optimization-parity.test.ts
@@ -30,6 +30,20 @@ async function createImageFixture(router: "app" | "pages"): Promise {
if (router === "app") {
await fs.rm(path.join(rootDir, "app", "alias-test"), { recursive: true, force: true });
await fs.rm(path.join(rootDir, "app", "baseurl-test"), { recursive: true, force: true });
+ const renderResponseHeaderFile = path.join(rootDir, "app", "lib", "render-response-header.ts");
+ const renderResponseHeaderImport = path
+ .relative(
+ path.dirname(renderResponseHeaderFile),
+ path.resolve(import.meta.dirname, "../packages/vinext/src/shims/headers.js"),
+ )
+ .replace(/\\/g, "/");
+ const normalizedImport = renderResponseHeaderImport.startsWith(".")
+ ? renderResponseHeaderImport
+ : `./${renderResponseHeaderImport}`;
+ await fs.writeFile(
+ renderResponseHeaderFile,
+ `export { appendRenderResponseHeader } from ${JSON.stringify(normalizedImport)};\n`,
+ );
await fs.mkdir(path.join(rootDir, "app", "image-parity"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "app", "image-parity", "page.tsx"),
diff --git a/tests/isr-cache.test.ts b/tests/isr-cache.test.ts
index 15de2cf8e..f492c9a15 100644
--- a/tests/isr-cache.test.ts
+++ b/tests/isr-cache.test.ts
@@ -146,9 +146,22 @@ describe("buildAppPageCacheValue", () => {
});
it("includes status when provided", () => {
- const value = buildAppPageCacheValue("app", undefined, 200);
+ const value = buildAppPageCacheValue("app", undefined, undefined, 200);
expect(value.status).toBe(200);
});
+
+ it("includes serialized render headers when provided", () => {
+ const value = buildAppPageCacheValue(
+ "app",
+ undefined,
+ { "x-rendered": "yes", "set-cookie": ["a=1; Path=/"] },
+ 200,
+ );
+ expect(value.headers).toEqual({
+ "x-rendered": "yes",
+ "set-cookie": ["a=1; Path=/"],
+ });
+ });
});
// ─── Revalidate duration tracking ───────────────────────────────────────
diff --git a/tests/nextjs-compat/app-routes.test.ts b/tests/nextjs-compat/app-routes.test.ts
index 492586187..eb2c94f40 100644
--- a/tests/nextjs-compat/app-routes.test.ts
+++ b/tests/nextjs-compat/app-routes.test.ts
@@ -243,6 +243,30 @@ describe("Next.js compat: app-routes", () => {
expect(res.status).toBe(404);
});
+ it("redirect() preserves queued Set-Cookie headers", async () => {
+ const res = await fetch(`${baseUrl}/api/redirect-with-cookie`, {
+ redirect: "manual",
+ });
+ expect(res.status).toBe(307);
+ expect(res.headers.get("location")).toContain("/about");
+ expect(res.headers.getSetCookie()).toContain("route-special=redirect; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("notFound() preserves queued Set-Cookie headers", async () => {
+ const res = await fetch(`${baseUrl}/api/not-found-with-cookie`);
+ expect(res.status).toBe(404);
+ expect(res.headers.getSetCookie()).toContain("route-special=not-found; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
+ it("unauthorized() preserves queued Set-Cookie headers", async () => {
+ const res = await fetch(`${baseUrl}/api/unauthorized-with-cookie`);
+ expect(res.status).toBe(401);
+ expect(res.headers.getSetCookie()).toContain("route-special=unauthorized; Path=/; HttpOnly");
+ expect(await res.text()).toBe("");
+ });
+
// ── cookies().set() ──────────────────────────────────────────
// Next.js: tests cookie setting via cookies() in route handlers
// Using pre-existing /api/set-cookie
diff --git a/tests/shims.test.ts b/tests/shims.test.ts
index 3b714fc20..634a2708a 100644
--- a/tests/shims.test.ts
+++ b/tests/shims.test.ts
@@ -1081,6 +1081,153 @@ describe("next/headers phase-aware cookies", () => {
});
});
+describe("next/headers render response headers", () => {
+ it("preserves repeated non-cookie headers and multi-value Set-Cookie deterministically", async () => {
+ const {
+ setHeadersContext,
+ appendRenderResponseHeader,
+ restoreRenderResponseHeaders,
+ peekRenderResponseHeaders,
+ setRenderResponseHeader,
+ deleteRenderResponseHeader,
+ consumeRenderResponseHeaders,
+ markDynamicUsage,
+ peekDynamicUsage,
+ restoreDynamicUsage,
+ consumeDynamicUsage,
+ } = await import("../packages/vinext/src/shims/headers.js");
+
+ setHeadersContext({
+ headers: new Headers(),
+ cookies: new Map(),
+ });
+
+ try {
+ appendRenderResponseHeader("x-test", "one");
+ appendRenderResponseHeader("x-test", "two");
+ setRenderResponseHeader("x-test", "final");
+ appendRenderResponseHeader("Vary", "x-render-one");
+ appendRenderResponseHeader("Vary", "x-render-two");
+ appendRenderResponseHeader("Set-Cookie", "a=1; Path=/");
+ appendRenderResponseHeader("Set-Cookie", "b=2; Path=/");
+ deleteRenderResponseHeader("x-missing");
+ appendRenderResponseHeader("x-delete-me", "temp");
+ deleteRenderResponseHeader("x-delete-me");
+
+ expect(peekRenderResponseHeaders()).toEqual({
+ "set-cookie": ["a=1; Path=/", "b=2; Path=/"],
+ Vary: ["x-render-one", "x-render-two"],
+ "x-test": "final",
+ });
+ expect(peekRenderResponseHeaders()).toEqual({
+ "set-cookie": ["a=1; Path=/", "b=2; Path=/"],
+ Vary: ["x-render-one", "x-render-two"],
+ "x-test": "final",
+ });
+ expect(consumeRenderResponseHeaders()).toEqual({
+ "set-cookie": ["a=1; Path=/", "b=2; Path=/"],
+ Vary: ["x-render-one", "x-render-two"],
+ "x-test": "final",
+ });
+ expect(consumeRenderResponseHeaders()).toBeUndefined();
+
+ restoreRenderResponseHeaders({
+ "set-cookie": ["restored=1; Path=/"],
+ vary: ["x-restored-one", "x-restored-two"],
+ "x-restored": "yes",
+ });
+ expect(peekRenderResponseHeaders()).toEqual({
+ "set-cookie": ["restored=1; Path=/"],
+ vary: ["x-restored-one", "x-restored-two"],
+ "x-restored": "yes",
+ });
+
+ expect(peekDynamicUsage()).toBe(false);
+ markDynamicUsage();
+ expect(peekDynamicUsage()).toBe(true);
+ restoreDynamicUsage(false);
+ expect(peekDynamicUsage()).toBe(false);
+ restoreDynamicUsage(true);
+ expect(peekDynamicUsage()).toBe(true);
+ expect(consumeDynamicUsage()).toBe(true);
+ expect(peekDynamicUsage()).toBe(false);
+ } finally {
+ setHeadersContext(null);
+ }
+ });
+
+ it("keeps cookie helper queues separate from generic header serialization until consumed", async () => {
+ const {
+ setHeadersContext,
+ setHeadersAccessPhase,
+ cookies,
+ draftMode,
+ getAndClearPendingCookies,
+ getDraftModeCookieHeader,
+ consumeRenderResponseHeaders,
+ } = await import("../packages/vinext/src/shims/headers.js");
+
+ setHeadersContext({
+ headers: new Headers(),
+ cookies: new Map(),
+ });
+
+ const previousPhase = setHeadersAccessPhase("action");
+ try {
+ const cookieStore = await cookies();
+ cookieStore.set("session", "abc");
+ const draft = await draftMode();
+ draft.enable();
+
+ expect(getAndClearPendingCookies()).toEqual([expect.stringContaining("session=abc")]);
+ expect(getDraftModeCookieHeader()).toContain("__prerender_bypass=");
+ expect(consumeRenderResponseHeaders()).toBeUndefined();
+ } finally {
+ setHeadersAccessPhase(previousPhase);
+ setHeadersContext(null);
+ }
+ });
+
+ it("serializes public cookie and draft-mode mutations into render response headers", async () => {
+ const {
+ setHeadersContext,
+ setHeadersAccessPhase,
+ cookies,
+ draftMode,
+ consumeRenderResponseHeaders,
+ } = await import("../packages/vinext/src/shims/headers.js");
+
+ setHeadersContext({
+ headers: new Headers(),
+ cookies: new Map(),
+ });
+
+ const previousPhase = setHeadersAccessPhase("action");
+ try {
+ const cookieStore = await cookies();
+ cookieStore.set("session", "abc", { path: "/", httpOnly: true });
+ cookieStore.delete("session");
+
+ const draft = await draftMode();
+ draft.enable();
+ draft.disable();
+
+ expect(consumeRenderResponseHeaders()).toEqual({
+ "set-cookie": [
+ "session=abc; Path=/; HttpOnly",
+ expect.stringContaining("session=; Path=/; Expires="),
+ expect.stringContaining("__prerender_bypass="),
+ expect.stringContaining("__prerender_bypass=; Path=/; HttpOnly; SameSite=Lax"),
+ ],
+ });
+ expect(consumeRenderResponseHeaders()).toBeUndefined();
+ } finally {
+ setHeadersAccessPhase(previousPhase);
+ setHeadersContext(null);
+ }
+ });
+});
+
describe("next/server shim", () => {
it("NextRequest wraps a standard Request with nextUrl and cookies", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");