Skip to content

Commit 37e4e69

Browse files
fix(app-router): tag cached pages by route pattern (#923)
Cached App Router pages only carried implicit revalidatePath tags derived from the resolved URL. That left dynamic routes tagged with concrete values, so pattern-scoped invalidation like revalidatePath("/blog/[slug]", "layout") could miss entries rendered at /blog/hello. The generated RSC entry now delegates implicit tag construction to a typed helper that combines exact pathname tags with route-segment-derived layout, page, and route handler tags. Focused tests cover dynamic route patterns, route groups, root aliases, and route handler leaves. Refs #921
1 parent 213173a commit 37e4e69

7 files changed

Lines changed: 285 additions & 219 deletions

File tree

knip.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export default {
7070
// probed via require.resolve
7171
"next-intl",
7272

73-
// vitest reporter
74-
"agent",
73+
// vitest reporter used outside CI
74+
...(process.env.CI ? [] : ["agent"]),
7575

7676
// internal module name, not an actual dependency
7777
"private-next-instrumentation-client",

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

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const appRouteHandlerCachePath = resolveEntryPath(
5757
"../server/app-route-handler-cache.js",
5858
import.meta.url,
5959
);
60+
const implicitTagsPath = resolveEntryPath("../server/implicit-tags.js", import.meta.url);
6061
const appPageCachePath = resolveEntryPath("../server/app-page-cache.js", import.meta.url);
6162
const appPageExecutionPath = resolveEntryPath("../server/app-page-execution.js", import.meta.url);
6263
const appPageBoundaryRenderPath = resolveEntryPath(
@@ -437,6 +438,7 @@ import {
437438
import {
438439
applyRouteHandlerMiddlewareContext as __applyRouteHandlerMiddlewareContext,
439440
} from ${JSON.stringify(appRouteHandlerResponsePath)};
441+
import { buildPageCacheTags } from ${JSON.stringify(implicitTagsPath)};
440442
import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
441443
import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
442444
import { setRootParams as __setRootParams, pickRootParams as __pickRootParams } from ${JSON.stringify(rootParamsShimPath)};
@@ -502,27 +504,6 @@ async function __isrSet(key, data, revalidateSeconds, tags) {
502504
const handler = getCacheHandler();
503505
await handler.set(key, data, { revalidate: revalidateSeconds, tags: Array.isArray(tags) ? tags : [] });
504506
}
505-
function __pageCacheTags(pathname, extraTags) {
506-
const tags = [pathname, "_N_T_" + pathname];
507-
// Layout hierarchy tags — matches Next.js getDerivedTags.
508-
tags.push("_N_T_/layout");
509-
const segments = pathname.split("/");
510-
let built = "";
511-
for (let i = 1; i < segments.length; i++) {
512-
if (segments[i]) {
513-
built += "/" + segments[i];
514-
tags.push("_N_T_" + built + "/layout");
515-
}
516-
}
517-
// Leaf page tag — revalidatePath(path, "page") targets this.
518-
tags.push("_N_T_" + built + "/page");
519-
if (Array.isArray(extraTags)) {
520-
for (const tag of extraTags) {
521-
if (!tags.includes(tag)) tags.push(tag);
522-
}
523-
}
524-
return tags;
525-
}
526507
// Note: cache entries are written with \`headers: undefined\`. Next.js stores
527508
// response headers (e.g. set-cookie from cookies().set() during render) in the
528509
// cache entry so they can be replayed on HIT. We don't do this because:
@@ -2122,7 +2103,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
21222103
}
21232104
21242105
const { route, params } = match;
2125-
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));
2106+
setCurrentFetchSoftTags(
2107+
buildPageCacheTags(cleanPathname, [], route.routeSegments, route.routeHandler ? "route" : "page"),
2108+
);
21262109
21272110
// Update navigation context with matched params
21282111
setNavigationContext({
@@ -2137,6 +2120,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
21372120
const handler = route.routeHandler;
21382121
const method = request.method.toUpperCase();
21392122
const revalidateSeconds = __getAppRouteHandlerRevalidateSeconds(handler);
2123+
const __buildRouteHandlerPageCacheTags = function(pathname, extraTags) {
2124+
return buildPageCacheTags(pathname, extraTags, route.routeSegments, "route");
2125+
};
21402126
if (__hasAppRouteHandlerDefaultExport(handler) && process.env.NODE_ENV === "development") {
21412127
console.error(
21422128
"[vinext] Detected default export in route handler " + route.pattern + ". Export a named export for each HTTP method instead.",
@@ -2179,7 +2165,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
21792165
) {
21802166
const __cachedRouteResponse = await __readAppRouteHandlerCacheResponse({
21812167
basePath: __basePath,
2182-
buildPageCacheTags: __pageCacheTags,
2168+
buildPageCacheTags: __buildRouteHandlerPageCacheTags,
21832169
cleanPathname,
21842170
clearRequestContext: function() {
21852171
__clearRequestContext();
@@ -2209,7 +2195,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
22092195
});
22102196
await _runWithUnifiedCtx(__revalUCtx, async () => {
22112197
_ensureFetchPatch();
2212-
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));
2198+
setCurrentFetchSoftTags(buildPageCacheTags(cleanPathname, [], route.routeSegments, "route"));
22132199
await renderFn();
22142200
});
22152201
},
@@ -2224,7 +2210,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
22242210
if (typeof handlerFn === "function") {
22252211
return __executeAppRouteHandler({
22262212
basePath: __basePath,
2227-
buildPageCacheTags: __pageCacheTags,
2213+
buildPageCacheTags: __buildRouteHandlerPageCacheTags,
22282214
cleanPathname,
22292215
clearRequestContext: function() {
22302216
__clearRequestContext();
@@ -2370,7 +2356,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
23702356
});
23712357
return _runWithUnifiedCtx(__revalUCtx, async () => {
23722358
_ensureFetchPatch();
2373-
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));
2359+
setCurrentFetchSoftTags(buildPageCacheTags(cleanPathname, [], route.routeSegments, "page"));
23742360
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
23752361
// Slot context (X-Vinext-Mounted-Slots) is inherited from the
23762362
// triggering request so the regen result is cached under the
@@ -2400,7 +2386,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
24002386
__clearRequestContext();
24012387
const __freshHtml = await __readAppPageTextStream(__revalHtmlStream);
24022388
const __freshRscData = await __revalRscCapture.capturedRscDataPromise;
2403-
const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags());
2389+
const __pageTags = buildPageCacheTags(cleanPathname, getCollectedFetchTags(), route.routeSegments, "page");
24042390
return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags };
24052391
});
24062392
},
@@ -2565,7 +2551,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
25652551
getFontStyles: _getSSRFontStyles,
25662552
getNavigationContext: _getNavigationContext,
25672553
getPageTags() {
2568-
return __pageCacheTags(cleanPathname, getCollectedFetchTags());
2554+
return buildPageCacheTags(cleanPathname, getCollectedFetchTags(), route.routeSegments, "page");
25692555
},
25702556
getRequestCacheLife() {
25712557
return _consumeRequestScopedCacheLife();
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const NEXT_CACHE_IMPLICIT_TAG_ID = "_N_T_";
2+
3+
type AppCacheLeafKind = "page" | "route";
4+
5+
function appendUnique(tags: string[], tag: string): void {
6+
if (!tags.includes(tag)) tags.push(tag);
7+
}
8+
9+
// App route segments come from raw filesystem directories, so dynamic
10+
// segments are already in bracket notation such as [slug] or [...all].
11+
function normalizeRouteSegment(segment: string): string | null {
12+
if (!segment || segment === "." || segment.startsWith("@")) return null;
13+
return segment;
14+
}
15+
16+
function buildRouteCachePath(routeSegments: string[], leafKind: AppCacheLeafKind): string {
17+
const parts: string[] = [];
18+
for (const segment of routeSegments) {
19+
const normalized = normalizeRouteSegment(segment);
20+
if (normalized) parts.push(normalized);
21+
}
22+
parts.push(leafKind);
23+
return `/${parts.join("/")}`;
24+
}
25+
26+
function appendDerivedTags(tags: string[], routePath: string): void {
27+
appendUnique(tags, `${NEXT_CACHE_IMPLICIT_TAG_ID}/layout`);
28+
29+
if (!routePath.startsWith("/")) return;
30+
31+
const routeParts = routePath.split("/");
32+
const leafIndex = routeParts.length - 1;
33+
for (let i = 1; i <= routeParts.length; i++) {
34+
let currentPathname = routeParts.slice(0, i).join("/");
35+
if (!currentPathname) continue;
36+
37+
const isLeaf = i - 1 === leafIndex;
38+
if (!isLeaf) {
39+
currentPathname = `${currentPathname}/layout`;
40+
}
41+
42+
appendUnique(tags, `${NEXT_CACHE_IMPLICIT_TAG_ID}${currentPathname}`);
43+
}
44+
}
45+
46+
export function buildPageCacheTags(
47+
pathname: string,
48+
extraTags: string[],
49+
routeSegments: string[],
50+
leafKind: AppCacheLeafKind,
51+
): string[] {
52+
const tags = [pathname, `${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`];
53+
if (pathname === "/") appendUnique(tags, `${NEXT_CACHE_IMPLICIT_TAG_ID}/index`);
54+
if (pathname === "/index") appendUnique(tags, `${NEXT_CACHE_IMPLICIT_TAG_ID}/`);
55+
appendDerivedTags(tags, buildRouteCachePath(routeSegments, leafKind));
56+
57+
for (const tag of extraTags) {
58+
appendUnique(tags, tag);
59+
}
60+
61+
return tags;
62+
}

0 commit comments

Comments
 (0)