Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0e63ab0
fix(link): schedule segment-cache prefetch phases
james-elicx Jul 1, 2026
475a828
fix(link): align segment prefetch scheduling parity
james-elicx Jul 1, 2026
328baf8
fix(app-router): skip scheduled prefetch intent for bots
james-elicx Jul 1, 2026
709bfed
fix(app-router): keep segment prefetch params concrete
james-elicx Jul 1, 2026
bb6a17c
fix(link): mark viewport prefetch starts ignored
james-elicx Jul 1, 2026
13822f8
fix(app-router): keep auto prefetch segment markers concrete
james-elicx Jul 1, 2026
8aaf402
fix(app-router): tolerate missing source page metadata
james-elicx Jul 1, 2026
abdeac4
test(app-router): align intercepted rsc cache write expectation
james-elicx Jul 1, 2026
3c2ac22
fix(app-router): extract segment prefetch scheduler
james-elicx Jul 1, 2026
5583a5c
fix(app-router): keep scheduler aliases private
james-elicx Jul 1, 2026
f584994
fix(link): preserve immediate app viewport prefetches
james-elicx Jul 1, 2026
54055c7
fix(link): rewarm invalidated segment prefetch links
james-elicx Jul 1, 2026
e172419
fix(link): preserve app prefetch scheduling parity
james-elicx Jul 1, 2026
83bb70a
fix(link): restart dirty segment prefetch tasks
james-elicx Jul 1, 2026
20929ba
fix(link): ping late app runtime links
james-elicx Jul 1, 2026
3fc7b5b
fix(link): preserve segment prefetch re-entry state
james-elicx Jul 1, 2026
02c2667
Merge remote-tracking branch 'origin/main' into codex/fix-segment-cac…
james-elicx Jul 1, 2026
32da0ea
fix(link): preserve generic loading-shell prefetch headers
james-elicx Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export type PrerenderRouteResult =
outputFiles: string[];
revalidate: number | false;
expire?: number;
stale?: number;
/**
* The concrete prerendered URL path, e.g. `/blog/hello-world`.
* Only present when the route is dynamic and `path` differs from `route`.
Expand Down Expand Up @@ -1567,6 +1568,9 @@ export async function prerenderApp({
...(typeof renderedRevalidate === "number"
? { expire: renderedCacheControl.expire }
: {}),
...(typeof renderedCacheControl.stale === "number"
? { stale: renderedCacheControl.stale }
: {}),
router: "app",
...(htmlRender.linkHeader ? { headers: { link: htmlRender.linkHeader } } : {}),
...(urlPath !== routePattern ? { path: urlPath } : {}),
Expand Down Expand Up @@ -1674,10 +1678,10 @@ export async function prerenderApp({
}

function resolveRenderedCacheControl(
requestCacheLife: { expire?: number; revalidate?: number },
requestCacheLife: { expire?: number; revalidate?: number; stale?: number },
cacheControl: string,
fallbackExpireSeconds: number,
): { expire: number; revalidate?: number } {
): { expire: number; revalidate?: number; stale?: number } {
const sMaxage = parseCacheControlSeconds(cacheControl, "s-maxage");
const staleWhileRevalidate = parseCacheControlSeconds(cacheControl, "stale-while-revalidate");
const revalidate =
Expand All @@ -1690,26 +1694,34 @@ function resolveRenderedCacheControl(
sMaxage,
staleWhileRevalidate,
}),
...(requestCacheLife.stale === undefined ? {} : { stale: requestCacheLife.stale }),
...(revalidate === undefined ? {} : { revalidate }),
};
}

function readPrerenderCacheLifeHeader(
headers: Headers,
): { expire?: number; revalidate?: number } | null {
): { expire?: number; revalidate?: number; stale?: number } | null {
const value = headers.get(VINEXT_PRERENDER_CACHE_LIFE_HEADER);
if (!value) return null;

try {
const parsed = JSON.parse(value) as { expire?: unknown; revalidate?: unknown };
const cacheLife: { expire?: number; revalidate?: number } = {};
const parsed = JSON.parse(value) as { expire?: unknown; revalidate?: unknown; stale?: unknown };
const cacheLife: { expire?: number; revalidate?: number; stale?: number } = {};
if (typeof parsed.revalidate === "number" && Number.isFinite(parsed.revalidate)) {
cacheLife.revalidate = parsed.revalidate;
}
if (typeof parsed.stale === "number" && Number.isFinite(parsed.stale)) {
cacheLife.stale = parsed.stale;
}
if (typeof parsed.expire === "number" && Number.isFinite(parsed.expire)) {
cacheLife.expire = parsed.expire;
}
return cacheLife.revalidate === undefined && cacheLife.expire === undefined ? null : cacheLife;
return cacheLife.revalidate === undefined &&
cacheLife.stale === undefined &&
cacheLife.expire === undefined
? null
: cacheLife;
} catch {
return null;
}
Expand Down
39 changes: 34 additions & 5 deletions packages/vinext/src/server/app-page-cache-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,39 @@ type AppPageCacheSetter = (
revalidateSeconds: number,
tags: string[],
expireSeconds?: number,
staleSeconds?: number,
) => Promise<void>;
type AppPageRscCacheKeyBuilder = (
pathname: string,
mountedSlotsHeader?: string | null,
renderMode?: AppRscRenderMode,
interceptionContext?: string | null,
segmentPrefetchPath?: string | null,
) => string;
type AppPageRequestCacheLife = {
revalidate?: number;
stale?: number;
expire?: number;
};
type BuildAppPageCacheRenderObservation = (input: {
cacheTags: readonly string[];
state: AppPageRenderObservationState;
}) => RenderObservation;

function setAppPageCacheEntry(
setter: AppPageCacheSetter,
key: string,
data: CachedAppPageValue,
revalidateSeconds: number,
tags: string[],
expireSeconds: number | undefined,
staleSeconds: number | undefined,
): Promise<void> {
return staleSeconds === undefined
? setter(key, data, revalidateSeconds, tags, expireSeconds)
: setter(key, data, revalidateSeconds, tags, expireSeconds, staleSeconds);
}

type FinalizeAppPageHtmlCacheResponseOptions = {
capturedDynamicUsageBeforeContextCleanup?: () => boolean;
capturedRscDataPromise: Promise<ArrayBuffer> | null;
Expand Down Expand Up @@ -71,6 +88,7 @@ type ScheduleAppPageRscCacheWriteOptions = {
interceptionContext?: string | null;
mountedSlotsHeader?: string | null;
renderMode?: AppRscRenderMode;
segmentPrefetchPath?: string | null;
preserveClientResponseHeaders?: boolean;
expireSeconds?: number;
revalidateSeconds: number | null;
Expand All @@ -96,9 +114,10 @@ function resolveAppPageCacheWritePolicy(options: {
expireSeconds?: number;
requestCacheLife?: AppPageRequestCacheLife | null;
revalidateSeconds: number | null;
}): { expireSeconds?: number; revalidateSeconds: number } | null {
}): { expireSeconds?: number; revalidateSeconds: number; staleSeconds?: number } | null {
let revalidateSeconds = options.revalidateSeconds;
let expireSeconds = options.expireSeconds;
let staleSeconds: number | undefined;
const requestCacheLife = options.requestCacheLife;

if (requestCacheLife?.revalidate !== undefined) {
Expand All @@ -110,12 +129,15 @@ function resolveAppPageCacheWritePolicy(options: {
if (requestCacheLife?.expire !== undefined) {
expireSeconds = requestCacheLife.expire;
}
if (requestCacheLife?.stale !== undefined) {
staleSeconds = requestCacheLife.stale;
}

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

return { expireSeconds, revalidateSeconds };
return { expireSeconds, revalidateSeconds, staleSeconds };
}

export function finalizeAppPageHtmlCacheResponse(
Expand Down Expand Up @@ -176,7 +198,8 @@ export function finalizeAppPageHtmlCacheResponse(
});
const linkHeader = response.headers.get("link");
const writes = [
options.isrSet(
setAppPageCacheEntry(
options.isrSet,
htmlKey,
buildAppPageCacheValue(
cachedHtml,
Expand All @@ -188,18 +211,21 @@ export function finalizeAppPageHtmlCacheResponse(
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
cachePolicy.staleSeconds,
),
];

if (options.capturedRscDataPromise) {
writes.push(
options.capturedRscDataPromise.then((rscData) =>
options.isrSet(
setAppPageCacheEntry(
options.isrSet,
rscKey,
buildAppPageCacheValue("", rscData, 200, rscRenderObservation),
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
cachePolicy.staleSeconds,
),
),
);
Expand Down Expand Up @@ -257,6 +283,7 @@ export function scheduleAppPageRscCacheWrite(
options.mountedSlotsHeader,
options.renderMode,
options.interceptionContext,
options.segmentPrefetchPath,
);
const cachePromise = (async () => {
try {
Expand Down Expand Up @@ -284,12 +311,14 @@ export function scheduleAppPageRscCacheWrite(
cacheTags: pageTags,
state: observationState,
});
await options.isrSet(
await setAppPageCacheEntry(
options.isrSet,
rscKey,
buildAppPageCacheValue("", rscData, 200, rscRenderObservation),
cachePolicy.revalidateSeconds,
pageTags,
cachePolicy.expireSeconds,
cachePolicy.staleSeconds,
);
options.isrDebug?.("RSC cache written", rscKey);
} catch (cacheError) {
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/app-page-cache-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export async function renderAppPageCacheArtifacts(
tags,
cacheControl:
typeof cacheLife?.revalidate === "number"
? { revalidate: cacheLife.revalidate, expire: cacheLife.expire }
? { revalidate: cacheLife.revalidate, stale: cacheLife.stale, expire: cacheLife.expire }
: undefined,
};

Expand Down
Loading
Loading