Skip to content

Commit 9a26846

Browse files
conico974vicb
andauthored
Add support for SWR in revalidateTag (#1168)
Co-authored-by: Victor Berchet <victor@suumit.com>
1 parent c7d6425 commit 9a26846

17 files changed

Lines changed: 1264 additions & 279 deletions

File tree

.changeset/fine-aliens-smoke.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
Add support for SWR (stale-while-revalidate) in `revalidateTag`
6+
7+
See the [AWS implementation](https://github.com/opennextjs/opennextjs-aws/pull/1122) for more details.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { revalidateTag } from "next/cache";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
export async function GET() {
6+
// Revalidate with expire:10 to mark the tag as stale immediately and set expiry to 10 seconds later
7+
revalidateTag("revalidate-stale", { expire: 10 });
8+
9+
return new Response("ok");
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { unstable_cache } from "next/cache";
2+
3+
const getCachedTime = unstable_cache(async () => new Date().toISOString(), ["stale-revalidate-time"], {
4+
tags: ["revalidate-stale"],
5+
// Long revalidate time so the cache only expires via revalidateTag
6+
revalidate: 3600,
7+
});
8+
9+
export default async function StaleRevalidateTag() {
10+
const cachedTime = await getCachedTime();
11+
return (
12+
<div>
13+
<p data-testid="cached-time">Cached time: {cachedTime}</p>
14+
</div>
15+
);
16+
}

examples/e2e/app-router/e2e/revalidateTag.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,69 @@ test("Revalidate tag", async ({ page, request }) => {
6868
expect(nextCacheHeaderNested).toEqual("HIT");
6969
});
7070

71+
test("Revalidate tag - stale data served first", async ({ page, request }) => {
72+
test.setTimeout(45000);
73+
74+
// Warm up: visit several times so the page is firmly cached (HIT)
75+
for (let i = 0; i < 3; i++) {
76+
await page.goto("/revalidate-tag/stale");
77+
await page.waitForSelector("[data-testid='cached-time']");
78+
}
79+
80+
let responsePromise = page.waitForResponse(
81+
(response) => response.url().includes("/revalidate-tag/stale") && response.status() === 200
82+
);
83+
await page.goto("/revalidate-tag/stale");
84+
const warmupResponse = await responsePromise;
85+
const warmupHeaders = warmupResponse.headers();
86+
const warmupCache = warmupHeaders["x-nextjs-cache"] ?? warmupHeaders["x-opennext-cache"];
87+
// Must be cached after warm-up
88+
expect(warmupCache).toMatch(/^(HIT|STALE)$/);
89+
90+
// Record the currently cached value
91+
const cachedTimeEl = page.getByTestId("cached-time");
92+
const originalTime = await cachedTimeEl.textContent();
93+
94+
// Trigger revalidateTag WITHOUT expire — marks the tag stale but does NOT
95+
// immediately purge the cache; the next request should get STALE data.
96+
const revalidateRes = await request.get("/api/revalidate-tag-stale");
97+
expect(revalidateRes.status()).toEqual(200);
98+
expect(await revalidateRes.text()).toEqual("ok");
99+
100+
// First request after revalidation — expect STALE header and OLD content
101+
responsePromise = page.waitForResponse(
102+
(response) => response.url().includes("/revalidate-tag/stale") && response.status() === 200
103+
);
104+
await page.goto("/revalidate-tag/stale");
105+
const staleResponse = await responsePromise;
106+
const staleHeaders = staleResponse.headers();
107+
const staleCache = staleHeaders["x-nextjs-cache"] ?? staleHeaders["x-opennext-cache"];
108+
expect(staleCache).toMatch(/^(STALE|HIT)$/);
109+
110+
const staleTime = await page.getByTestId("cached-time").textContent();
111+
// Stale content must match the pre-revalidation value
112+
expect(staleTime).toEqual(originalTime);
113+
114+
// Wait for the background regeneration to finish, then verify fresh data
115+
let freshTime: string | null = null;
116+
let attempts = 0;
117+
while (attempts < 10) {
118+
await page.waitForTimeout(2000);
119+
await page.goto("/revalidate-tag/stale");
120+
121+
freshTime = await page.getByTestId("cached-time").textContent();
122+
if (freshTime !== originalTime) {
123+
break;
124+
}
125+
126+
attempts++;
127+
}
128+
129+
// After background regen the cached value must have been updated
130+
expect(freshTime).not.toBeNull();
131+
expect(freshTime).not.toEqual(originalTime);
132+
});
133+
71134
test("Revalidate path", async ({ page, request }) => {
72135
await page.goto("/revalidate-path");
73136

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

33
export function GET() {
4-
revalidateTag("fullyTagged", "max");
4+
// Revalidate the tag with expire:0 to mark it for immediate revalidation; the next request should be a MISS
5+
revalidateTag("fullyTagged", { expire: 0 });
56
return new Response("DONE");
67
}

packages/cloudflare/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@
5454
"dependencies": {
5555
"@ast-grep/napi": "^0.40.5",
5656
"@dotenvx/dotenvx": "catalog:",
57-
"@opennextjs/aws": "3.9.16",
57+
"@opennextjs/aws": "3.10.1",
5858
"cloudflare": "^4.4.1",
59+
"comment-json": "^4.5.1",
5960
"enquirer": "^2.4.1",
6061
"glob": "catalog:",
61-
"comment-json": "^4.5.1",
6262
"ts-tqdm": "^0.8.6",
6363
"yargs": "catalog:"
6464
},
@@ -86,7 +86,7 @@
8686
"vitest": "catalog:"
8787
},
8888
"peerDependencies": {
89-
"wrangler": "catalog:",
90-
"next": ">=15.5.15 || >=16.2.3"
89+
"next": ">=15.5.15 || >=16.2.3",
90+
"wrangler": "catalog:"
9191
}
9292
}

packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,81 @@ describe("DOShardedTagCache class", () => {
3737
// @ts-expect-error - testing private method
3838
expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled();
3939
expect(cache.sql.exec).toHaveBeenCalledWith(
40-
`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)`
40+
`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER, stale INTEGER, expire INTEGER DEFAULT NULL)`
4141
);
42+
expect(cache.sql.exec).toHaveBeenCalledWith(
43+
`ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER DEFAULT NULL`
44+
);
45+
});
46+
47+
describe("getTagData", () => {
48+
it("should return an empty object for empty tags", async () => {
49+
const cache = createDOShardedTagCache();
50+
const result = await cache.getTagData([]);
51+
expect(result).toEqual({});
52+
expect(cache.sql.exec).not.toHaveBeenCalledWith(expect.stringContaining("SELECT"), expect.anything());
53+
});
54+
55+
it("should query all columns and return a record", async () => {
56+
const cache = createDOShardedTagCache();
57+
vi.mocked(cache.sql.exec).mockReturnValueOnce({
58+
toArray: () => [
59+
{ tag: "tag1", revalidatedAt: 1000, stale: 1000, expire: null },
60+
{ tag: "tag2", revalidatedAt: 2000, stale: 1500, expire: 9999 },
61+
],
62+
} as ReturnType<SqlStorage["exec"]>);
63+
const result = await cache.getTagData(["tag1", "tag2"]);
64+
expect(result).toEqual({
65+
tag1: { revalidatedAt: 1000, stale: 1000, expire: null },
66+
tag2: { revalidatedAt: 2000, stale: 1500, expire: 9999 },
67+
});
68+
});
69+
70+
it("should return empty object on SQL error", async () => {
71+
const cache = createDOShardedTagCache();
72+
vi.mocked(cache.sql.exec).mockImplementationOnce(() => {
73+
throw new Error("sql error");
74+
});
75+
const result = await cache.getTagData(["tag1"]);
76+
expect(result).toEqual({});
77+
});
78+
});
79+
80+
describe("writeTags", () => {
81+
it("should write string tags using the old format (backward compat)", async () => {
82+
const cache = createDOShardedTagCache();
83+
await cache.writeTags(["tag1", "tag2"], 1000);
84+
expect(cache.sql.exec).toHaveBeenCalledWith(
85+
`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`,
86+
"tag1",
87+
1000,
88+
1000
89+
);
90+
expect(cache.sql.exec).toHaveBeenCalledWith(
91+
`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`,
92+
"tag2",
93+
1000,
94+
1000
95+
);
96+
});
97+
98+
it("should write object tags using stale and expire", async () => {
99+
const cache = createDOShardedTagCache();
100+
await cache.writeTags([{ tag: "tag1", stale: 5000, expire: 9999 }]);
101+
expect(cache.sql.exec).toHaveBeenCalledWith(
102+
`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`,
103+
"tag1",
104+
5000,
105+
5000,
106+
9999
107+
);
108+
});
109+
110+
it("should return early for empty tags", async () => {
111+
const cache = createDOShardedTagCache();
112+
const execCallsBeforeCreate = vi.mocked(cache.sql.exec).mock.calls.length;
113+
await cache.writeTags([]);
114+
expect(vi.mocked(cache.sql.exec).mock.calls.length).toBe(execCallsBeforeCreate);
115+
});
42116
});
43117
});

0 commit comments

Comments
 (0)