Skip to content

Commit 4241f5d

Browse files
fix: skip page RSC ISR caching for dynamic requests (#629)
* fix: skip page RSC ISR caching for dynamic requests * test: align page ISR assertions with helper extraction
1 parent 8366217 commit 4241f5d

9 files changed

Lines changed: 392 additions & 119 deletions

File tree

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ import { readAppRouteHandlerCacheResponse as __readAppRouteHandlerCacheResponse
365365
import {
366366
finalizeAppPageHtmlCacheResponse as __finalizeAppPageHtmlCacheResponse,
367367
readAppPageCacheResponse as __readAppPageCacheResponse,
368+
scheduleAppPageRscCacheWrite as __scheduleAppPageRscCacheWrite,
368369
} from ${JSON.stringify(appPageCachePath)};
369370
import {
370371
buildAppPageHtmlResponse as __buildAppPageHtmlResponse,
@@ -2792,7 +2793,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
27922793
// The RSC stream is consumed lazily - components render when chunks are read.
27932794
// If we clear context now, headers()/cookies() will fail during rendering.
27942795
// Context will be cleared when the next request starts (via runWithRequestContext).
2796+
const __dynamicUsedInRsc = consumeDynamicUsage();
27952797
const __rscResponsePolicy = __resolveAppPageRscResponsePolicy({
2798+
dynamicUsedDuringBuild: __dynamicUsedInRsc,
27962799
isDynamicError,
27972800
isForceDynamic,
27982801
isForceStatic,
@@ -2814,21 +2817,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
28142817
// For ISR-eligible RSC requests in production: write rscData to its own key.
28152818
// HTML is stored under a separate key (written by the HTML path below) so
28162819
// these writes never race or clobber each other.
2817-
if (process.env.NODE_ENV === "production" && __isrRscDataPromise) {
2818-
const __isrKeyRsc = __isrRscKey(cleanPathname);
2819-
const __revalSecsRsc = revalidateSeconds;
2820-
const __rscWritePromise = (async () => {
2821-
try {
2822-
const __rscDataForCache = await __isrRscDataPromise;
2823-
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
2824-
await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags);
2825-
__isrDebug?.("RSC cache written", __isrKeyRsc);
2826-
} catch (__rscWriteErr) {
2827-
console.error("[vinext] ISR RSC cache write error:", __rscWriteErr);
2828-
}
2829-
})();
2830-
_getRequestExecutionContext()?.waitUntil(__rscWritePromise);
2831-
}
2820+
__scheduleAppPageRscCacheWrite({
2821+
capturedRscDataPromise: process.env.NODE_ENV === "production" ? __isrRscDataPromise : null,
2822+
cleanPathname,
2823+
consumeDynamicUsage,
2824+
dynamicUsedDuringBuild: __dynamicUsedInRsc,
2825+
getPageTags() {
2826+
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
2827+
},
2828+
isrDebug: __isrDebug,
2829+
isrRscKey: __isrRscKey,
2830+
isrSet: __isrSet,
2831+
revalidateSeconds,
2832+
waitUntil(promise) {
2833+
_getRequestExecutionContext()?.waitUntil(promise);
2834+
},
2835+
});
28322836
return __rscResponse;
28332837
}
28342838

packages/vinext/src/server/app-page-cache.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ export interface FinalizeAppPageHtmlCacheResponseOptions {
4949
waitUntil?: (promise: Promise<void>) => void;
5050
}
5151

52+
export interface ScheduleAppPageRscCacheWriteOptions {
53+
capturedRscDataPromise: Promise<ArrayBuffer> | null;
54+
cleanPathname: string;
55+
consumeDynamicUsage: () => boolean;
56+
dynamicUsedDuringBuild: boolean;
57+
getPageTags: () => string[];
58+
isrDebug?: AppPageDebugLogger;
59+
isrRscKey: (pathname: string) => string;
60+
isrSet: AppPageCacheSetter;
61+
revalidateSeconds: number;
62+
waitUntil?: (promise: Promise<void>) => void;
63+
}
64+
5265
function buildAppPageCacheControl(
5366
cacheState: BuildAppPageCachedResponseOptions["cacheState"],
5467
revalidateSeconds: number,
@@ -250,3 +263,42 @@ export function finalizeAppPageHtmlCacheResponse(
250263
headers: response.headers,
251264
});
252265
}
266+
267+
export function scheduleAppPageRscCacheWrite(
268+
options: ScheduleAppPageRscCacheWriteOptions,
269+
): boolean {
270+
const capturedRscDataPromise = options.capturedRscDataPromise;
271+
if (!capturedRscDataPromise || options.dynamicUsedDuringBuild) {
272+
return false;
273+
}
274+
275+
const rscKey = options.isrRscKey(options.cleanPathname);
276+
const cachePromise = (async () => {
277+
try {
278+
const rscData = await capturedRscDataPromise;
279+
280+
// Two-phase dynamic detection:
281+
// 1. dynamicUsedDuringBuild catches searchParams-driven opt-in before the
282+
// RSC response is sent.
283+
// 2. consumeDynamicUsage() here catches APIs that fire while the RSC
284+
// stream is consumed (headers(), cookies(), noStore()).
285+
if (options.consumeDynamicUsage()) {
286+
options.isrDebug?.("RSC cache write skipped (dynamic usage during render)", rscKey);
287+
return;
288+
}
289+
290+
await options.isrSet(
291+
rscKey,
292+
buildAppPageCacheValue("", rscData, 200),
293+
options.revalidateSeconds,
294+
options.getPageTags(),
295+
);
296+
options.isrDebug?.("RSC cache written", rscKey);
297+
} catch (cacheError) {
298+
console.error("[vinext] ISR RSC cache write error:", cacheError);
299+
}
300+
})();
301+
302+
options.waitUntil?.(cachePromise);
303+
return true;
304+
}

packages/vinext/src/server/app-page-response.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@ export interface AppPageResponsePolicy {
1515
cacheState?: "MISS" | "STATIC";
1616
}
1717

18-
export interface ResolveAppPageRscResponsePolicyOptions {
18+
interface ResolveAppPageResponsePolicyBaseOptions {
1919
isDynamicError: boolean;
2020
isForceDynamic: boolean;
2121
isForceStatic: boolean;
2222
isProduction: boolean;
2323
revalidateSeconds: number | null;
2424
}
2525

26-
export interface ResolveAppPageHtmlResponsePolicyOptions extends ResolveAppPageRscResponsePolicyOptions {
26+
export interface ResolveAppPageRscResponsePolicyOptions extends ResolveAppPageResponsePolicyBaseOptions {
27+
dynamicUsedDuringBuild: boolean;
28+
}
29+
30+
export interface ResolveAppPageHtmlResponsePolicyOptions extends ResolveAppPageResponsePolicyBaseOptions {
2731
dynamicUsedDuringRender: boolean;
2832
}
2933

@@ -74,7 +78,7 @@ function applyTimingHeader(headers: Headers, timing?: AppPageResponseTiming): vo
7478
export function resolveAppPageRscResponsePolicy(
7579
options: ResolveAppPageRscResponsePolicyOptions,
7680
): AppPageResponsePolicy {
77-
if (options.isForceDynamic) {
81+
if (options.isForceDynamic || options.dynamicUsedDuringBuild) {
7882
return { cacheControl: NO_STORE_CACHE_CONTROL };
7983
}
8084

0 commit comments

Comments
 (0)