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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/app-router-cloudflare/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Counter } from "./components/counter";

// The example's E2E coverage expects the rendered timestamp to change per request.
export const dynamic = "force-dynamic";

export default function HomePage() {
return (
<main>
Expand Down
27 changes: 18 additions & 9 deletions packages/vinext/src/server/app-page-cache-finalizer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CachedAppPageValue } from "vinext/shims/cache";
import type { AppRscRenderMode } from "./app-rsc-render-mode.js";
import { applyCdnResponseHeaders } from "./cache-control.js";
import { applyCdnResponseHeaders, INFINITE_CACHE_SECONDS } from "./cache-control.js";
import { setCacheStateHeaders } from "./cache-headers.js";
import { NEXTJS_CACHE_HEADER, VINEXT_CACHE_HEADER } from "./headers.js";
import {
Expand Down Expand Up @@ -39,6 +39,7 @@ type FinalizeAppPageHtmlCacheResponseOptions = {
capturedRscDataPromise: Promise<ArrayBuffer> | null;
cleanPathname: string;
consumeDynamicUsage: () => boolean;
dynamicUsageCheckComplete?: boolean;
consumeRenderObservationState?: () => AppPageRenderObservationState;
createHtmlRenderObservation?: BuildAppPageCacheRenderObservation;
createRscRenderObservation?: BuildAppPageCacheRenderObservation;
Expand All @@ -63,6 +64,7 @@ type ScheduleAppPageRscCacheWriteOptions = {
consumeRenderObservationState?: () => AppPageRenderObservationState;
createRscRenderObservation?: BuildAppPageCacheRenderObservation;
dynamicUsedDuringBuild: boolean;
dynamicUsageCheckComplete?: boolean;
getPageTags: () => string[];
getRequestCacheLife?: () => AppPageRequestCacheLife | null;
isrDebug?: AppPageDebugLogger;
Expand All @@ -81,10 +83,14 @@ type ScheduleAppPageRscCacheWriteOptions = {
function applyPendingDynamicCdnHeaders(
headers: Headers,
tags?: readonly string[],
options: { omitCacheState?: boolean } = {},
options: { dynamicUsageCheckComplete?: boolean; omitCacheState?: boolean } = {},
): void {
const cacheable = headers.get("Cache-Control") ?? "";
applyCdnResponseHeaders(headers, { cacheControl: cacheable, pendingDynamicCheck: true, tags });
applyCdnResponseHeaders(headers, {
cacheControl: cacheable,
pendingDynamicCheck: options.dynamicUsageCheckComplete !== true,
tags,
});
if (options.omitCacheState === true) {
headers.delete(VINEXT_CACHE_HEADER);
headers.delete(NEXTJS_CACHE_HEADER);
Expand All @@ -98,24 +104,25 @@ function resolveAppPageCacheWritePolicy(options: {
requestCacheLife?: AppPageRequestCacheLife | null;
revalidateSeconds: number | null;
}): { expireSeconds?: number; revalidateSeconds: number } | null {
let revalidateSeconds = options.revalidateSeconds;
let revalidateSeconds = options.revalidateSeconds ?? INFINITE_CACHE_SECONDS;
let expireSeconds = options.expireSeconds;
const requestCacheLife = options.requestCacheLife;

if (requestCacheLife?.revalidate !== undefined) {
revalidateSeconds =
revalidateSeconds === null
? requestCacheLife.revalidate
: Math.min(revalidateSeconds, requestCacheLife.revalidate);
revalidateSeconds = Math.min(revalidateSeconds, requestCacheLife.revalidate);
}
if (requestCacheLife?.expire !== undefined) {
expireSeconds = requestCacheLife.expire;
}

if (revalidateSeconds === null || Number.isNaN(revalidateSeconds) || revalidateSeconds <= 0) {
if (Number.isNaN(revalidateSeconds) || revalidateSeconds <= 0) {
return null;
}

if (revalidateSeconds === Infinity) {
revalidateSeconds = INFINITE_CACHE_SECONDS;
}

return { expireSeconds, revalidateSeconds };
}

Expand All @@ -138,6 +145,7 @@ export function finalizeAppPageHtmlCacheResponse(
const clientHeaders = new Headers(response.headers);
if (options.preserveClientResponseHeaders !== true) {
applyPendingDynamicCdnHeaders(clientHeaders, options.getPageTags(), {
dynamicUsageCheckComplete: options.dynamicUsageCheckComplete,
omitCacheState: options.omitPendingDynamicCacheState === true,
});
}
Expand Down Expand Up @@ -237,6 +245,7 @@ export function finalizeAppPageRscCacheResponse(

const clientHeaders = new Headers(response.headers);
applyPendingDynamicCdnHeaders(clientHeaders, options.getPageTags(), {
dynamicUsageCheckComplete: options.dynamicUsageCheckComplete,
omitCacheState: options.omitPendingDynamicCacheState === true,
});

Expand Down
12 changes: 1 addition & 11 deletions packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,17 +461,9 @@ export function shouldReadAppPageCache(options: {
}

function resolveAppPageCacheReadRevalidateSeconds(options: {
isDynamicError: boolean;
isForceStatic: boolean;
revalidateSeconds: number | null;
}): number {
if (options.revalidateSeconds === null && (options.isForceStatic || options.isDynamicError)) {
return Infinity;
}

// cacheLife-only routes discover their actual revalidate during the fresh
// render; this seed only gets them into the cache read path.
return options.revalidateSeconds ?? 0;
return options.revalidateSeconds ?? Infinity;
}

export function hasSearchParams(searchParams: URLSearchParams | null | undefined): boolean {
Expand Down Expand Up @@ -652,8 +644,6 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
renderMode: options.renderMode,
expireSeconds: options.expireSeconds,
revalidateSeconds: resolveAppPageCacheReadRevalidateSeconds({
isDynamicError,
isForceStatic,
revalidateSeconds: currentRevalidateSeconds,
}),
renderFreshPageForCache: async () => {
Expand Down
54 changes: 40 additions & 14 deletions packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { ReactFormState } from "react-dom/client";
import type { NavigationContext } from "vinext/shims/navigation";
import type { CachedAppPageValue } from "vinext/shims/cache-handler";
import { getCdnCacheAdapter } from "vinext/shims/cdn-cache";
import type { RootParams } from "vinext/shims/root-params";
import { runWithFetchDedupe } from "vinext/shims/fetch-cache";
import { AppElementsWire, isAppElementsRecord, type AppOutgoingElements } from "./app-elements.js";
Expand All @@ -20,6 +21,7 @@ import {
type LayoutClassificationOptions,
} from "./app-page-execution.js";
import { probeAppPageBeforeRender } from "./app-page-probe.js";
import { readStreamAsText } from "../utils/text-stream.js";
import {
buildAppPageHtmlResponse,
buildAppPageRscResponse,
Expand Down Expand Up @@ -279,15 +281,9 @@ function applyRequestCacheLife(options: {
}

function resolveAppPageCacheWriteRevalidateSeconds(options: {
isDynamicError: boolean;
isForceStatic: boolean;
revalidateSeconds: number | null;
}): number | null {
if (options.revalidateSeconds === null && (options.isForceStatic || options.isDynamicError)) {
return Infinity;
}

return options.revalidateSeconds;
return options.revalidateSeconds ?? Infinity;
}

function readRootBoundaryId(element: Readonly<Record<string, unknown>>): string | null {
Expand Down Expand Up @@ -806,7 +802,18 @@ export async function renderAppPageLifecycle(
}));
}

const dynamicUsedDuringBuild = options.consumeDynamicUsage();
let dynamicUsedDuringBuild = options.consumeDynamicUsage();
let dynamicUsageCheckComplete = false;
if (
options.isProduction &&
!dynamicUsedDuringBuild &&
shouldCaptureRscForCacheMetadata &&
(options.isEdgeRuntime || !getCdnCacheAdapter().ownsBackgroundRevalidation)
) {
await settleCapturedRscRenderForCacheMetadata(capturedRscDataRef.value);
dynamicUsedDuringBuild = options.consumeDynamicUsage();
dynamicUsageCheckComplete = true;
}
// When skip transport is enabled, omit cacheState because the response is a
// per-client payload, not a shared-cache MISS/HIT artifact. The absence also
// keeps finalizeAppPageRscCacheResponse from overwriting no-store.
Expand Down Expand Up @@ -890,6 +897,7 @@ export async function renderAppPageLifecycle(
});
},
dynamicUsedDuringBuild,
dynamicUsageCheckComplete,
getPageTags() {
return options.getPageTags();
},
Expand All @@ -906,8 +914,6 @@ export async function renderAppPageLifecycle(
preserveClientResponseHeaders: rscResponsePolicy.cacheState !== "MISS",
expireSeconds,
revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({
isDynamicError: options.isDynamicError,
isForceStatic: options.isForceStatic,
revalidateSeconds,
}),
waitUntil(promise) {
Expand Down Expand Up @@ -1074,13 +1080,30 @@ export async function renderAppPageLifecycle(
// Clearing the context synchronously here would race those executions, causing
// headers()/cookies() to see a null context on warm (module-cached) requests.
// See: https://github.com/cloudflare/vinext/issues/660
const safeHtmlStream = deferUntilStreamConsumed(htmlStream, () => {
let safeHtmlStream = deferUntilStreamConsumed(htmlStream, () => {
dynamicUsedBeforeContextCleanup =
dynamicUsedBeforeContextCleanup || options.consumeDynamicUsage();
dynamicUsedDuringHtmlRender = dynamicUsedBeforeContextCleanup;
options.clearRequestContext();
});

let dynamicUsageCheckComplete = false;
if (
options.isProduction &&
!dynamicUsedDuringRender &&
shouldCaptureRscForCacheMetadata &&
(options.isEdgeRuntime || !getCdnCacheAdapter().ownsBackgroundRevalidation)
) {
// CDN-managed caches need the final cache policy before response headers
// leave the origin. Buffer candidate static HTML so late request-API usage
// can still demote the response to no-store instead of poisoning the edge.
const bufferedHtml = await readStreamAsText(safeHtmlStream);
safeHtmlStream = new Response(bufferedHtml).body!;
dynamicUsedDuringRender = dynamicUsedBeforeContextCleanup || options.consumeDynamicUsage();
dynamicUsedDuringHtmlRender = dynamicUsedDuringRender;
dynamicUsageCheckComplete = true;
}

const htmlResponsePolicy = resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender,
isProgressiveActionRender: options.isProgressiveActionRender === true,
Expand Down Expand Up @@ -1129,7 +1152,11 @@ export async function renderAppPageLifecycle(
options.isProgressiveActionRender !== true &&
!dynamicUsedDuringRender;

if (htmlResponsePolicy.shouldWriteToCache || shouldSpeculativelyWriteCache) {
if (
htmlResponsePolicy.shouldWriteToCache ||
shouldSpeculativelyWriteCache ||
(dynamicUsageCheckComplete && htmlResponsePolicy.cacheState === "MISS")
) {
const isrResponse = buildAppPageHtmlResponse(safeHtmlStream, {
draftCookie,
linkHeader,
Expand All @@ -1151,6 +1178,7 @@ export async function renderAppPageLifecycle(
capturedRscDataPromise: capturedRscDataRef.value,
cleanPathname: options.cleanPathname,
consumeDynamicUsage: options.consumeDynamicUsage,
dynamicUsageCheckComplete,
consumeRenderObservationState: options.consumeRenderObservationState,
createHtmlRenderObservation(input) {
return createAppPageRenderObservation({
Expand Down Expand Up @@ -1191,8 +1219,6 @@ export async function renderAppPageLifecycle(
preserveClientResponseHeaders: !htmlResponsePolicy.shouldWriteToCache,
expireSeconds,
revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({
isDynamicError: options.isDynamicError,
isForceStatic: options.isForceStatic,
revalidateSeconds,
}),
waitUntil(cachePromise) {
Expand Down
37 changes: 26 additions & 11 deletions packages/vinext/src/server/app-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,21 @@ export function resolveAppPageRscResponsePolicy(
return { cacheControl: NO_STORE_CACHE_CONTROL };
}

if (
((options.isForceStatic || options.isDynamicError) && !options.revalidateSeconds) ||
options.revalidateSeconds === Infinity
) {
if (options.revalidateSeconds === null) {
if (!options.isProduction && !options.isForceStatic && !options.isDynamicError) {
return {};
}

return {
cacheControl: STATIC_CACHE_CONTROL,
cacheState:
options.isProduction && !options.isForceStatic && !options.isDynamicError
? "MISS"
: "STATIC",
};
}

if (options.revalidateSeconds === Infinity) {
return {
cacheControl: STATIC_CACHE_CONTROL,
cacheState: "STATIC",
Expand Down Expand Up @@ -221,18 +232,22 @@ export function resolveAppPageHtmlResponsePolicy(
};
}

if ((options.isForceStatic || options.isDynamicError) && options.revalidateSeconds === null) {
if (options.dynamicUsedDuringRender) {
return {
cacheControl: STATIC_CACHE_CONTROL,
cacheState: options.isProduction ? "MISS" : "STATIC",
shouldWriteToCache: options.isProduction,
cacheControl: NO_STORE_CACHE_CONTROL,
shouldWriteToCache: false,
};
}

if (options.dynamicUsedDuringRender) {
if (options.revalidateSeconds === null) {
if (!options.isProduction && !options.isForceStatic && !options.isDynamicError) {
return { shouldWriteToCache: false };
}

return {
cacheControl: NO_STORE_CACHE_CONTROL,
shouldWriteToCache: false,
cacheControl: STATIC_CACHE_CONTROL,
cacheState: options.isProduction ? "MISS" : "STATIC",
shouldWriteToCache: options.isProduction,
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/vinext/src/server/cache-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export const BROWSER_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalid

export const STATIC_CACHE_CONTROL = "s-maxage=31536000, stale-while-revalidate";

// Matches Next.js's JSON-safe sentinel for `revalidate = false`.
export const INFINITE_CACHE_SECONDS = 0xfffffffe;

const STALE_REVALIDATE_CACHE_CONTROL = "s-maxage=0, stale-while-revalidate";

export const NO_STORE_CACHE_CONTROL = "no-store, must-revalidate";
Expand Down Expand Up @@ -92,7 +95,7 @@ export function buildCachedRevalidateCacheControl(
revalidateSeconds: number,
expireSeconds?: number,
): string {
if (revalidateSeconds === Infinity) {
if (revalidateSeconds === Infinity || revalidateSeconds === INFINITE_CACHE_SECONDS) {
return STATIC_CACHE_CONTROL;
}

Expand Down
9 changes: 6 additions & 3 deletions packages/vinext/src/server/isr-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "./app-rsc-render-mode.js";
import { normalizeAppPageInterceptionProofPathname } from "./app-page-render-identity.js";
import type { RenderObservation } from "./cache-proof.js";
import { INFINITE_CACHE_SECONDS } from "./cache-control.js";
export { normalizeMountedSlotsHeader };

/**
Expand Down Expand Up @@ -179,14 +180,16 @@ export async function isrSet(
tags?: string[],
expireSeconds?: number,
): Promise<void> {
const storedRevalidateSeconds =
revalidateSeconds === Infinity ? INFINITE_CACHE_SECONDS : revalidateSeconds;
await getCdnCacheAdapter().set(key, data, {
cacheControl:
expireSeconds === undefined
? { revalidate: revalidateSeconds }
: { revalidate: revalidateSeconds, expire: expireSeconds },
? { revalidate: storedRevalidateSeconds }
: { revalidate: storedRevalidateSeconds, expire: expireSeconds },
// `revalidate` is the legacy vinext CacheHandler context field. `expire`
// is new metadata and intentionally only lives inside cacheControl.
revalidate: revalidateSeconds,
revalidate: storedRevalidateSeconds,
tags: tags ?? [],
});
}
Expand Down
9 changes: 7 additions & 2 deletions packages/vinext/src/server/isr-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { CacheControlMetadata } from "vinext/shims/cache-handler";
import {
buildCachedRevalidateCacheControl,
buildRevalidateCacheControl,
INFINITE_CACHE_SECONDS,
NEVER_CACHE_CONTROL,
NO_STORE_CACHE_CONTROL,
STATIC_CACHE_CONTROL,
Expand Down Expand Up @@ -100,7 +101,9 @@ function buildCacheControl(
): string {
if (kind === "app-route") {
if (revalidate === 0) return NEVER_CACHE_CONTROL;
if (revalidate === Infinity) return STATIC_CACHE_CONTROL;
if (revalidate === Infinity || revalidate === INFINITE_CACHE_SECONDS) {
return STATIC_CACHE_CONTROL;
}
}
return buildCachedRevalidateCacheControl(disposition, revalidate, expire);
}
Expand Down Expand Up @@ -166,7 +169,9 @@ export function buildAppRouteMissIsrCacheControl(
expireSeconds?: number,
): string {
if (revalidateSeconds === 0) return NEVER_CACHE_CONTROL;
if (revalidateSeconds === Infinity) return STATIC_CACHE_CONTROL;
if (revalidateSeconds === Infinity || revalidateSeconds === INFINITE_CACHE_SECONDS) {
return STATIC_CACHE_CONTROL;
}
return buildRevalidateCacheControl(revalidateSeconds, expireSeconds);
}

Expand Down
Loading
Loading