Skip to content

Commit b93034d

Browse files
authored
Fix issues with revalidateTag/revalidatePath (#470)
* fix clearing data cache with revalidatePath * fix revalidatePath for fetch with no tag * fix revalidateTag for next 15 * e2e test * fix incorrectly writing to tag cache on set * added comment * Create gold-paws-laugh.md
1 parent 1dd2b16 commit b93034d

File tree

9 files changed

+147
-19
lines changed

9 files changed

+147
-19
lines changed

.changeset/gold-paws-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"open-next": patch
3+
---
4+
5+
Fix issues with revalidateTag/revalidatePath
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { revalidatePath } from "next/cache";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
export async function GET() {
6+
revalidatePath("/revalidate-path");
7+
8+
return new Response("ok");
9+
}

examples/app-router/app/api/revalidate-tag/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { revalidateTag } from "next/cache";
22

3+
export const dynamic = "force-dynamic";
4+
35
export async function GET() {
46
revalidateTag("revalidate");
57

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export default async function Page() {
2+
const timeInParis = await fetch(
3+
"http://worldtimeapi.org/api/timezone/Europe/Paris",
4+
{
5+
next: {
6+
tags: ["path"],
7+
},
8+
},
9+
);
10+
// This one doesn't have a tag
11+
const timeInLondon = await fetch(
12+
"http://worldtimeapi.org/api/timezone/Europe/London",
13+
);
14+
const timeInParisJson = await timeInParis.json();
15+
const parisTime = timeInParisJson.datetime;
16+
const timeInLondonJson = await timeInLondon.json();
17+
const londonTime = timeInLondonJson.datetime;
18+
return (
19+
<div>
20+
<h1>Time in Paris</h1>
21+
<p>Paris: {parisTime}</p>
22+
<h1>Time in London</h1>
23+
<p>London: {londonTime}</p>
24+
</div>
25+
);
26+
}

examples/app-router/middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export function middleware(request: NextRequest) {
4343
}
4444

4545
// It is so that cloudfront doesn't cache the response
46-
if (path.startsWith("/revalidate-tag")) {
46+
if (
47+
path.startsWith("/revalidate-tag") ||
48+
path.startsWith("/revalidate-path")
49+
) {
4750
responseHeaders.set(
4851
"cache-control",
4952
"private, no-cache, no-store, max-age=0, must-revalidate",

packages/open-next/src/adapters/cache.ts

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,12 @@ export default class S3Cache {
104104
// fetchCache is for next 13.5 and above, kindHint is for next 14 and above and boolean is for earlier versions
105105
options?:
106106
| boolean
107-
| { fetchCache?: boolean; kindHint?: "app" | "pages" | "fetch" },
107+
| {
108+
fetchCache?: boolean;
109+
kindHint?: "app" | "pages" | "fetch";
110+
tags?: string[];
111+
softTags?: string[];
112+
},
108113
) {
109114
if (globalThis.disableIncrementalCache) {
110115
return null;
@@ -115,13 +120,16 @@ export default class S3Cache {
115120
? options.kindHint === "fetch"
116121
: options.fetchCache
117122
: options;
123+
124+
const softTags = typeof options === "object" ? options.softTags : [];
125+
const tags = typeof options === "object" ? options.tags : [];
118126
return isFetchCache
119-
? this.getFetchCache(key)
127+
? this.getFetchCache(key, softTags, tags)
120128
: this.getIncrementalCache(key);
121129
}
122130

123-
async getFetchCache(key: string) {
124-
debug("get fetch cache", { key });
131+
async getFetchCache(key: string, softTags?: string[], tags?: string[]) {
132+
debug("get fetch cache", { key, softTags, tags });
125133
try {
126134
const { value, lastModified } = await globalThis.incrementalCache.get(
127135
key,
@@ -139,6 +147,31 @@ export default class S3Cache {
139147

140148
if (value === undefined) return null;
141149

150+
// For cases where we don't have tags, we need to ensure that we insert at least an entry
151+
// for this specific paths, otherwise we might not be able to invalidate it
152+
if ((tags ?? []).length === 0) {
153+
// First we check if we have any tags for the given key
154+
const storedTags = await globalThis.tagCache.getByPath(key);
155+
if (storedTags.length === 0) {
156+
// Then we need to find the path for the given key
157+
const path = softTags?.find(
158+
(tag) =>
159+
tag.startsWith("_N_T_/") &&
160+
!tag.endsWith("layout") &&
161+
!tag.endsWith("page"),
162+
);
163+
if (path) {
164+
// And write the path with the tag
165+
await globalThis.tagCache.writeTags([
166+
{
167+
path: key,
168+
tag: path,
169+
},
170+
]);
171+
}
172+
}
173+
}
174+
142175
return {
143176
lastModified: _lastModified,
144177
value: value,
@@ -317,22 +350,45 @@ export default class S3Cache {
317350
}
318351
}
319352

320-
public async revalidateTag(tag: string) {
353+
public async revalidateTag(tags: string | string[]) {
321354
if (globalThis.disableDynamoDBCache || globalThis.disableIncrementalCache) {
322355
return;
323356
}
324357
try {
325-
debug("revalidateTag", tag);
326-
// Find all keys with the given tag
327-
const paths = await globalThis.tagCache.getByTag(tag);
328-
debug("Items", paths);
329-
// Update all keys with the given tag with revalidatedAt set to now
330-
await globalThis.tagCache.writeTags(
331-
paths?.map((path) => ({
332-
path: path,
333-
tag: tag,
334-
})) ?? [],
335-
);
358+
const _tags = Array.isArray(tags) ? tags : [tags];
359+
for (const tag of _tags) {
360+
debug("revalidateTag", tag);
361+
// Find all keys with the given tag
362+
const paths = await globalThis.tagCache.getByTag(tag);
363+
debug("Items", paths);
364+
const toInsert = paths.map((path) => ({
365+
path,
366+
tag,
367+
}));
368+
369+
// If the tag is a soft tag, we should also revalidate the hard tags
370+
if (tag.startsWith("_N_T_/")) {
371+
for (const path of paths) {
372+
// We need to find all hard tags for a given path
373+
const _tags = await globalThis.tagCache.getByPath(path);
374+
const hardTags = _tags.filter((t) => !t.startsWith("_N_T_/"));
375+
// For every hard tag, we need to find all paths and revalidate them
376+
for (const hardTag of hardTags) {
377+
const _paths = await globalThis.tagCache.getByTag(hardTag);
378+
debug({ hardTag, _paths });
379+
toInsert.push(
380+
..._paths.map((path) => ({
381+
path,
382+
tag: hardTag,
383+
})),
384+
);
385+
}
386+
}
387+
}
388+
389+
// Update all keys with the given tag with revalidatedAt set to now
390+
await globalThis.tagCache.writeTags(toInsert);
391+
}
336392
} catch (e) {
337393
error("Failed to revalidate tag", e);
338394
}

packages/open-next/src/cache/tag/dynamodb-lite.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ const tagCache: TagCache = {
7070

7171
const tags = Items?.map((item: any) => item.tag.S ?? "") ?? [];
7272
debug("tags for path", path, tags);
73-
return tags;
73+
// We need to remove the buildId from the path
74+
return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
7475
} catch (e) {
7576
error("Failed to get tags by path", e);
7677
return [];

packages/open-next/src/cache/tag/dynamodb.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ const tagCache: TagCache = {
5959
);
6060
const tags = result.Items?.map((item) => item.tag.S ?? "") ?? [];
6161
debug("tags for path", path, tags);
62-
return tags;
62+
// We need to remove the buildId from the path
63+
return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
6364
} catch (e) {
6465
error("Failed to get tags by path", e);
6566
return [];

packages/tests-e2e/tests/appRouter/revalidateTag.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,28 @@ test("Revalidate tag", async ({ page, request }) => {
6464
response = await responsePromise;
6565
expect(response.headers()["x-nextjs-cache"]).toEqual("HIT");
6666
});
67+
68+
test("Revalidate path", async ({ page, request }) => {
69+
await page.goto("/revalidate-path");
70+
71+
let elLayout = page.getByText("Paris:");
72+
const initialParis = await elLayout.textContent();
73+
74+
elLayout = page.getByText("London:");
75+
const initialLondon = await elLayout.textContent();
76+
77+
// Send revalidate path request
78+
const result = await request.get("/api/revalidate-path");
79+
expect(result.status()).toEqual(200);
80+
const text = await result.text();
81+
expect(text).toEqual("ok");
82+
83+
await page.goto("/revalidate-path");
84+
elLayout = page.getByText("Paris:");
85+
const newParis = await elLayout.textContent();
86+
expect(newParis).not.toEqual(initialParis);
87+
88+
elLayout = page.getByText("London:");
89+
const newLondon = await elLayout.textContent();
90+
expect(newLondon).not.toEqual(initialLondon);
91+
});

0 commit comments

Comments
 (0)