Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions examples/app-router-cloudflare/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Counter } from "./components/counter";

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
51 changes: 37 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,27 @@ 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)
) {
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 +1149,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 +1175,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 +1216,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