Skip to content

Commit b663d4b

Browse files
authored
fix(cache): inherit route revalidate for tagged fetches (#2985)
* fix(cache): inherit route revalidate for tagged fetches * fix(cache): inherit the live fetch revalidate interval
1 parent f4df1c1 commit b663d4b

10 files changed

Lines changed: 280 additions & 6 deletions

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,18 @@ function __resolveRouteDynamicConfig(route) {
500500
}).dynamicConfig ?? null;
501501
}
502502
503+
function __resolveRouteRevalidateSeconds(route) {
504+
return __resolveAppPageSegmentConfig({
505+
layouts: route.layouts,
506+
page: route.page,
507+
parallelSegments: Object.values(route.slots ?? {}).flatMap((slot) => [
508+
slot.layout,
509+
...(slot.configLayouts ?? []),
510+
slot.page ?? slot.default,
511+
]),
512+
}).revalidateSeconds;
513+
}
514+
503515
function __resolveRouteRuntime(route) {
504516
return __resolveAppPageSegmentConfig({
505517
layouts: route.layouts,
@@ -964,6 +976,9 @@ export default createAppRscHandler({
964976
resolveRouteFetchCacheMode(targetRoute) {
965977
return __resolveRouteFetchCacheMode(targetRoute);
966978
},
979+
resolveRouteRevalidateSeconds(targetRoute) {
980+
return __resolveRouteRevalidateSeconds(targetRoute);
981+
},
967982
resolveRouteDynamicConfig(targetRoute) {
968983
return __resolveRouteDynamicConfig(targetRoute);
969984
},
@@ -1197,6 +1212,9 @@ export default createAppRscHandler({
11971212
resolveRouteFetchCacheMode(targetRoute) {
11981213
return __resolveRouteFetchCacheMode(targetRoute);
11991214
},
1215+
resolveRouteRevalidateSeconds(targetRoute) {
1216+
return __resolveRouteRevalidateSeconds(targetRoute);
1217+
},
12001218
resolveRouteDynamicConfig(targetRoute) {
12011219
return __resolveRouteDynamicConfig(targetRoute);
12021220
},

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
peekDynamicFetchObservations,
3131
runWithFetchDedupe,
3232
setCurrentFetchCacheMode,
33+
setCurrentFetchRevalidate,
3334
setCurrentForceDynamicFetchDefault,
3435
setCurrentFetchSoftTags,
3536
setRefreshStaleFetchesInForeground,
@@ -396,6 +397,7 @@ export type DispatchAppPageOptions<TRoute extends AppPageDispatchRoute> = {
396397
revalidateSeconds: number | null;
397398
renderedPathAndSearch?: string | null;
398399
resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null;
400+
resolveRouteRevalidateSeconds?: (route: TRoute) => number | null;
399401
resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined;
400402
rootForbiddenModule?: AppPageModule | null;
401403
rootNotFoundModule?: AppPageModule | null;
@@ -541,6 +543,7 @@ async function runAppPageRevalidationContext<
541543
cleanPathname: string;
542544
displayPathname?: string;
543545
currentFetchCacheMode?: FetchCacheMode | null;
546+
currentFetchRevalidate?: number | null;
544547
draftModeSecret: string;
545548
dynamicConfig?: string;
546549
params: AppPageParams;
@@ -561,6 +564,7 @@ async function runAppPageRevalidationContext<
561564
const requestContext = createRequestContext({
562565
headersContext,
563566
currentFetchCacheMode: options.currentFetchCacheMode ?? null,
567+
currentFetchRevalidate: options.currentFetchRevalidate ?? null,
564568
currentForceDynamicFetchDefault: options.dynamicConfig === "force-dynamic",
565569
executionContext: getRequestExecutionContext(),
566570
unstableCacheRevalidation: "foreground",
@@ -659,6 +663,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
659663

660664
setCurrentFetchSoftTags(buildAppPageTags(options.cleanPathname, [], route.routeSegments));
661665
setCurrentFetchCacheMode(options.fetchCache ?? null);
666+
setCurrentFetchRevalidate(currentRevalidateSeconds);
662667
setCurrentForceDynamicFetchDefault(isForceDynamic);
663668

664669
if (options.hasPageModule && !options.hasPageDefaultExport) {
@@ -781,6 +786,9 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
781786
currentFetchCacheMode:
782787
options.resolveRouteFetchCacheMode?.(revalidationTarget.route) ??
783788
(revalidationTarget.route === route ? (options.fetchCache ?? null) : null),
789+
currentFetchRevalidate:
790+
options.resolveRouteRevalidateSeconds?.(revalidationTarget.route) ??
791+
(revalidationTarget.route === route ? currentRevalidateSeconds : null),
784792
draftModeSecret: options.draftModeSecret,
785793
dynamicConfig: revalidationDynamicConfig,
786794
params: revalidationTarget.navigationParams,
@@ -938,6 +946,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
938946
setHeadersContext(requestHeadersContext);
939947
}
940948
setCurrentFetchCacheMode(options.resolveRouteFetchCacheMode?.(interceptRoute) ?? null);
949+
setCurrentFetchRevalidate(options.resolveRouteRevalidateSeconds?.(interceptRoute) ?? null);
941950
setCurrentForceDynamicFetchDefault(sourceDynamicConfig === "force-dynamic");
942951
return options.buildPageElement(
943952
interceptRoute,

packages/vinext/src/server/app-route-handler-dispatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getCollectedFetchTags,
44
ensureFetchPatch,
55
setCurrentFetchCacheMode,
6+
setCurrentFetchRevalidate,
67
setCurrentFetchSoftTags,
78
setCurrentForceDynamicFetchDefault,
89
type FetchCacheMode,
@@ -117,6 +118,7 @@ async function runInRouteHandlerRevalidationContext(
117118
draftModeSecret: string;
118119
dynamicConfig?: string;
119120
fetchCacheMode: FetchCacheMode | null;
121+
revalidateSeconds: number | null;
120122
routePattern: string;
121123
routeSegments: string[];
122124
},
@@ -143,6 +145,7 @@ async function runInRouteHandlerRevalidationContext(
143145
// The revalidation render runs in a fresh request context, so the fetch
144146
// defaults applied by `dispatchAppRouteHandler` must be re-applied here.
145147
setCurrentFetchCacheMode(options.fetchCacheMode);
148+
setCurrentFetchRevalidate(options.revalidateSeconds);
146149
setCurrentForceDynamicFetchDefault(options.dynamicConfig === "force-dynamic");
147150
try {
148151
await renderFn();
@@ -232,6 +235,7 @@ export async function dispatchAppRouteHandler(
232235
// where handlers ignored their `fetchCache`/`force-dynamic` segment config.
233236
const fetchCacheMode = resolveAppRouteHandlerFetchCacheMode(handler);
234237
setCurrentFetchCacheMode(fetchCacheMode);
238+
setCurrentFetchRevalidate(revalidateSeconds);
235239
setCurrentForceDynamicFetchDefault(handler.dynamic === "force-dynamic");
236240

237241
if (
@@ -282,6 +286,7 @@ export async function dispatchAppRouteHandler(
282286
draftModeSecret: options.draftModeSecret,
283287
dynamicConfig: handler.dynamic,
284288
fetchCacheMode,
289+
revalidateSeconds,
285290
routePattern: route.pattern,
286291
routeSegments: route.routeSegments,
287292
},

packages/vinext/src/server/app-server-action-execution.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
type FetchCacheMode,
1313
setCurrentFetchCacheMode,
14+
setCurrentFetchRevalidate,
1415
setCurrentFetchSoftTags,
1516
setCurrentForceDynamicFetchDefault,
1617
} from "vinext/shims/fetch-cache";
@@ -322,6 +323,7 @@ export type HandleServerActionRscRequestOptions<
322323
) => BodyInit | null | Promise<BodyInit | null>;
323324
reportRequestError: AppServerActionErrorReporter;
324325
resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null;
326+
resolveRouteRevalidateSeconds?: (route: TRoute) => number | null;
325327
resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined;
326328
resolveRouteRuntime?: (route: TRoute) => AppServerActionRouteRuntime;
327329
request: Request;
@@ -1669,6 +1671,9 @@ export async function handleServerActionRscRequest<
16691671
setCurrentFetchCacheMode(
16701672
options.resolveRouteFetchCacheMode?.(actionRerenderTarget.route) ?? null,
16711673
);
1674+
setCurrentFetchRevalidate(
1675+
options.resolveRouteRevalidateSeconds?.(actionRerenderTarget.route) ?? null,
1676+
);
16721677
setCurrentForceDynamicFetchDefault(actionRerenderDynamicConfig === "force-dynamic");
16731678
setCurrentFetchSoftTags(
16741679
buildServerActionPageTags(actionRerenderTarget.route, options.cleanPathname),

packages/vinext/src/shims/fetch-cache.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { Buffer } from "node:buffer";
2424
import { getDataCacheHandler, type CachedFetchValue, type CacheHandler } from "./cache-handler.js";
2525
import { encodeCacheTags } from "../utils/encode-cache-tag.js";
2626
import { getOrCreateAls } from "./internal/als-registry.js";
27-
import { markDynamicUsage } from "./headers.js";
27+
import { getHeadersContext, markDynamicUsage } from "./headers.js";
2828
import { _hasPendingRevalidatedTag, _setRequestScopedCacheLife } from "./cache-request-state.js";
2929
import { getRequestExecutionContext } from "./request-context.js";
3030
import {
@@ -555,6 +555,7 @@ export type FetchCacheState = {
555555
currentRequestTags: string[];
556556
currentFetchSoftTags: string[];
557557
currentFetchCacheMode: FetchCacheMode | null;
558+
currentFetchRevalidate: number | null;
558559
currentForceDynamicFetchDefault: boolean;
559560
dynamicFetchUrls: Set<string>;
560561
refreshStaleFetchesInForeground: boolean;
@@ -592,6 +593,7 @@ const _fallbackState = (_g[_FALLBACK_KEY] ??= {
592593
currentRequestTags: [],
593594
currentFetchSoftTags: [],
594595
currentFetchCacheMode: null,
596+
currentFetchRevalidate: null,
595597
currentForceDynamicFetchDefault: false,
596598
dynamicFetchUrls: new Set<string>(),
597599
refreshStaleFetchesInForeground: false,
@@ -615,6 +617,7 @@ function _resetFallbackState(isFetchDedupeActive: boolean): void {
615617
_fallbackState.currentRequestTags = [];
616618
_fallbackState.currentFetchSoftTags = [];
617619
_fallbackState.currentFetchCacheMode = null;
620+
_fallbackState.currentFetchRevalidate = null;
618621
_fallbackState.currentForceDynamicFetchDefault = false;
619622
_fallbackState.dynamicFetchUrls = new Set<string>();
620623
_fallbackState.refreshStaleFetchesInForeground = false;
@@ -632,6 +635,13 @@ function recordDynamicFetchObservation(input: string | URL | Request): void {
632635

633636
function markUncachedFetchForPageOutput(input: string | URL | Request): void {
634637
recordDynamicFetchObservation(input);
638+
// Next.js lowers the active prerender store to zero when an uncached fetch
639+
// makes the render dynamic. `force-static` is the exception: dynamic usage
640+
// is suppressed there, so later metadata-only fetches keep inheriting the
641+
// configured route interval.
642+
if (getHeadersContext()?.forceStatic !== true) {
643+
_getState().currentFetchRevalidate = 0;
644+
}
635645
markDynamicUsage();
636646
}
637647

@@ -645,6 +655,13 @@ function recordFiniteFetchRevalidate(revalidateSeconds: number): void {
645655
}
646656
}
647657

658+
function lowerCurrentFetchRevalidate(revalidateSeconds: number): void {
659+
const state = _getState();
660+
if (state.currentFetchRevalidate === null || revalidateSeconds < state.currentFetchRevalidate) {
661+
state.currentFetchRevalidate = revalidateSeconds;
662+
}
663+
}
664+
648665
function shouldRefreshStaleFetchInForeground(): boolean {
649666
return _getState().refreshStaleFetchesInForeground;
650667
}
@@ -797,6 +814,10 @@ export function setCurrentFetchCacheMode(mode: FetchCacheMode | null): void {
797814
_getState().currentFetchCacheMode = mode;
798815
}
799816

817+
export function setCurrentFetchRevalidate(revalidate: number | null): void {
818+
_getState().currentFetchRevalidate = revalidate;
819+
}
820+
800821
export function setCurrentForceDynamicFetchDefault(enabled: boolean): void {
801822
_getState().currentForceDynamicFetchDefault = enabled;
802823
}
@@ -1178,11 +1199,20 @@ function createPatchedFetch(): typeof globalThis.fetch {
11781199
} else if (typeof nextOpts?.revalidate === "number" && nextOpts.revalidate > 0) {
11791200
revalidateSeconds = nextOpts.revalidate;
11801201
} else {
1181-
// Has `next` options but no explicit revalidate — Next.js defaults to
1182-
// caching when `next` is present (force-cache behavior).
1183-
// If only tags are specified, cache indefinitely.
1202+
// During prerender, a fetch without an explicit cache lifetime inherits
1203+
// the active route's revalidate value in Next.js. Tags make this fetch
1204+
// cacheable, but do not independently make it cache indefinitely.
11841205
if (nextOpts?.tags && nextOpts.tags.length > 0) {
1185-
revalidateSeconds = ONE_YEAR_SECONDS;
1206+
const routeRevalidate = _getState().currentFetchRevalidate;
1207+
if (routeRevalidate === 0) {
1208+
const cleanInit = stripNextFromInit(init, cacheDirective);
1209+
markUncachedFetchForPageOutput(input);
1210+
return dedupeFetch(input, cleanInit);
1211+
}
1212+
revalidateSeconds =
1213+
routeRevalidate === null || routeRevalidate === Infinity
1214+
? ONE_YEAR_SECONDS
1215+
: routeRevalidate;
11861216
} else {
11871217
// next: {} with no revalidate or tags — pass through
11881218
const cleanInit = stripNextFromInit(init, cacheDirective);
@@ -1199,6 +1229,12 @@ function createPatchedFetch(): typeof globalThis.fetch {
11991229
// recording both cacheable and dynamic is conservative — a false "unsafe"
12001230
// result costs performance, not correctness.
12011231
recordCacheableFetchObservation(input);
1232+
if (typeof nextOpts?.revalidate === "number" && nextOpts.revalidate > 0) {
1233+
// Upstream mutates the active prerender store when an explicit fetch
1234+
// lifetime is shorter. Later metadata-only fetches inherit that live
1235+
// minimum rather than the route's original segment-config seed.
1236+
lowerCurrentFetchRevalidate(nextOpts.revalidate);
1237+
}
12021238
recordFiniteFetchRevalidate(revalidateSeconds);
12031239
const reqTags = _getState().currentRequestTags;
12041240
const tags = encodeCacheTags(nextOpts?.tags ?? []);
@@ -1440,6 +1476,7 @@ export async function runWithFetchCache<T>(fn: () => Promise<T>): Promise<T> {
14401476
currentRequestTags: [],
14411477
currentFetchSoftTags: [],
14421478
currentFetchCacheMode: null,
1479+
currentFetchRevalidate: null,
14431480
currentForceDynamicFetchDefault: false,
14441481
dynamicFetchUrls: new Set<string>(),
14451482
refreshStaleFetchesInForeground: false,

packages/vinext/src/shims/unified-request-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
119119
currentRequestTags: [],
120120
currentFetchSoftTags: [],
121121
currentFetchCacheMode: null,
122+
currentFetchRevalidate: null,
122123
currentForceDynamicFetchDefault: false,
123124
dynamicFetchUrls: new Set<string>(),
124125
refreshStaleFetchesInForeground: false,

tests/app-page-dispatch.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ type CreateDispatchOptionsOverrides = {
318318
request?: Request;
319319
revalidateSeconds?: number | null;
320320
resolveRouteFetchCacheMode?: DispatchOptions["resolveRouteFetchCacheMode"];
321+
resolveRouteRevalidateSeconds?: DispatchOptions["resolveRouteRevalidateSeconds"];
321322
resolveRouteDynamicConfig?: DispatchOptions["resolveRouteDynamicConfig"];
322323
route?: TestRoute;
323324
scheduleBackgroundRegeneration?: DispatchOptions["scheduleBackgroundRegeneration"];
@@ -414,6 +415,7 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) {
414415
request: overrides.request ?? new Request("https://example.test/posts/hello"),
415416
revalidateSeconds: overrides.revalidateSeconds ?? null,
416417
resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode,
418+
resolveRouteRevalidateSeconds: overrides.resolveRouteRevalidateSeconds,
417419
resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig,
418420
route,
419421
runWithSuppressedHookWarning<T>(probe: () => Promise<T>) {
@@ -2393,6 +2395,9 @@ describe("app page dispatch", () => {
23932395
const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) =>
23942396
route === sourceRoute ? "force-cache" : null,
23952397
);
2398+
const resolveRouteRevalidateSeconds = vi.fn((route: TestRoute) =>
2399+
route === sourceRoute ? 30 : null,
2400+
);
23962401
const { options } = createDispatchOptions({
23972402
buildPageElement,
23982403
cleanPathname: "/photos/123",
@@ -2437,6 +2442,7 @@ describe("app page dispatch", () => {
24372442
mountedSlotsHeader: "slot:modal:/feed",
24382443
revalidateSeconds: 60,
24392444
resolveRouteFetchCacheMode,
2445+
resolveRouteRevalidateSeconds,
24402446
route: currentRoute,
24412447
scheduleBackgroundRegeneration,
24422448
searchParams: new URLSearchParams("tab=popular"),
@@ -2450,6 +2456,7 @@ describe("app page dispatch", () => {
24502456

24512457
const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0];
24522458
expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith(sourceRoute);
2459+
expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith(sourceRoute);
24532460
expect(routeArg).toBe(sourceRoute);
24542461
expect(paramsArg).toEqual({});
24552462
expect(searchParamsArg.toString()).toBe("tab=popular");

tests/app-route-handler-dispatch.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ describe("app route handler dispatch", () => {
403403
it("applies the force-dynamic fetch default before invoking the route handler", async () => {
404404
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
405405
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
406+
const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate");
406407
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
407408

408409
let forceDynamicDefaultAtHandlerTime: boolean | undefined;
@@ -449,14 +450,17 @@ describe("app route handler dispatch", () => {
449450
expect(response.status).toBe(200);
450451
expect(forceDynamicDefaultAtHandlerTime).toBe(true);
451452
expect(fetchCacheModeAtHandlerTime).toBeNull();
453+
expect(revalidateSpy).toHaveBeenCalledWith(null);
452454

453455
modeSpy.mockRestore();
456+
revalidateSpy.mockRestore();
454457
forceDynamicSpy.mockRestore();
455458
});
456459

457460
it("applies the handler's explicit fetchCache export without the force-dynamic default", async () => {
458461
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
459462
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
463+
const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate");
460464
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
461465

462466
let forceDynamicDefaultAtHandlerTime: boolean | undefined;
@@ -501,14 +505,17 @@ describe("app route handler dispatch", () => {
501505
expect(response.status).toBe(200);
502506
expect(forceDynamicDefaultAtHandlerTime).toBe(false);
503507
expect(fetchCacheModeAtHandlerTime).toBe("force-cache");
508+
expect(revalidateSpy).toHaveBeenCalledWith(null);
504509

505510
modeSpy.mockRestore();
511+
revalidateSpy.mockRestore();
506512
forceDynamicSpy.mockRestore();
507513
});
508514

509515
it("re-applies the handler's fetch cache mode inside the background regeneration context", async () => {
510516
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
511517
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
518+
const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate");
512519
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
513520

514521
let scheduledRender: (() => Promise<void>) | undefined;
@@ -566,9 +573,11 @@ describe("app route handler dispatch", () => {
566573

567574
expect(forceDynamicDefaultAtRegenTime).toBe(false);
568575
expect(fetchCacheModeAtRegenTime).toBe("force-cache");
576+
expect(revalidateSpy).toHaveBeenLastCalledWith(60);
569577
expect(afterRan).toBe(true);
570578

571579
modeSpy.mockRestore();
580+
revalidateSpy.mockRestore();
572581
forceDynamicSpy.mockRestore();
573582
});
574583

0 commit comments

Comments
 (0)