Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion examples/e2e/app-router/e2e/revalidateTag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
const staleResponse = await responsePromise;
const staleHeaders = staleResponse.headers();
const staleCache = staleHeaders["x-nextjs-cache"] ?? staleHeaders["x-opennext-cache"];
expect(staleCache).toMatch(/^(STALE|HIT)$/);
expect(staleCache).toMatch(/^(STALE)$/);

const staleTime = await page.getByTestId("cached-time").textContent();
// Stale content must match the pre-revalidation value
Expand All @@ -129,6 +129,19 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
// After background regen the cached value must have been updated
expect(freshTime).not.toBeNull();
expect(freshTime).not.toEqual(originalTime);

// Now we want to verfiy that the next entries stays fresh (HIT) after the first stale entry
responsePromise = page.waitForResponse(
(response) => response.url().includes("/revalidate-tag/stale") && response.status() === 200
);
await page.goto("/revalidate-tag/stale");
const finalResponse = await responsePromise;
const finalHeaders = finalResponse.headers();
const finalCache = finalHeaders["x-nextjs-cache"] ?? finalHeaders["x-opennext-cache"];
expect(finalCache).toEqual("HIT");

const finalTime = await page.getByTestId("cached-time").textContent();
expect(finalTime).toEqual(freshTime);
});

test("Revalidate path", async ({ page, request }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type Options = {
* Note: When this is enabled, make sure that the cache gets purged
* either by enabling the auto cache purging feature or manually.
*
* This is currently not compatible with swr types of revalidateTag (i.e. on Next 16+, anything different than `revalidateTag("tag", { expire: 0 })`),
Comment thread
vicb marked this conversation as resolved.
Outdated
* unless you also enable the `shouldLazilyUpdateOnCacheHit` option to make sure the cache gets updated in the background after a hit,
* and ONLY use it for pages, not data cache entries.
*
* @default `true` if the auto cache purging is enabled, `false` otherwise.
*/
bypassTagCacheOnCacheHit?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,17 @@ describe("D1NextModeTagCache", () => {
expect(result).toBe(false);
});

it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
const now = 2000;
vi.spyOn(Date, "now").mockReturnValue(now);
// revalidatedAt=500 <= lastModified=1000, so the stale window is from a previous ISR cycle
mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 1500, null]]);

const result = await tagCache.isStale(["tag1"], 1000);

expect(result).toBe(false);
});

it("should return false when expire <= now (tag expired)", async () => {
const now = 2000;
vi.spyOn(Date, "now").mockReturnValue(now);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,13 @@ export class D1NextModeTagCache implements NextModeTagCache {

const isStale = [...result.values()].some((v) => {
if (v == null) return false;
const { stale, expire } = v;
if (stale == null || stale <= (lastModified ?? now)) return false;
const { revalidatedAt, stale, expire } = v;
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
Comment thread
vicb marked this conversation as resolved.
Outdated
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
// after the page was generated, preventing a stale signal from a previous ISR cycle.
const isInStaleWindow =
stale != null && revalidatedAt > (lastModified ?? now) && (lastModified ?? now) <= stale;
Comment thread
vicb marked this conversation as resolved.
Outdated
if (!isInStaleWindow) return false;
return expire == null || expire > now;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ describe("DOShardedTagCache", () => {
expect(await cache.isStale(["tag1"], 200)).toBe(false);
});

it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
const cache = shardedDOTagCache();
cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]);
// revalidatedAt=100 <= lastModified=200, so the stale window is from a previous ISR cycle
getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 100, stale: 300, expire: null } });
expect(await cache.isStale(["tag1"], 200)).toBe(false);
});

it("should return from regional cache if available", async () => {
vi.useFakeTimers();
vi.setSystemTime(500);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,13 @@ class ShardedDOTagCache implements NextModeTagCache {
const tagData = await this.#resolveTagData(tags);
const result = [...tagData.values()].some((data) => {
if (data == null) return false;
const { stale, expire } = data;
if (stale == null || stale <= (lastModified ?? now)) return false;
const { revalidatedAt, stale, expire } = data;
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
// after the page was generated, preventing a stale signal from a previous ISR cycle.
const isInStaleWindow =
stale != null && revalidatedAt > (lastModified ?? now) && (lastModified ?? now) <= stale;
if (!isInStaleWindow) return false;
return expire == null || expire > now;
});
debugCache("ShardedDOTagCache", `isStale tags=${tags} at=${lastModified} -> ${result}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,19 @@ describe("KVNextModeTagCache", () => {
expect(result).toBe(false);
});

it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
const now = 2000;
vi.spyOn(Date, "now").mockReturnValue(now);
// revalidatedAt=500 <= lastModified=1000, so the stale window is from a previous ISR cycle
mockGet.mockResolvedValue(
new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 500, stale: 1500, expire: null }]])
);

const result = await tagCache.isStale(["tag1"], 1000);

expect(result).toBe(false);
});

it("should return false when expire <= now (tag expired)", async () => {
const now = 2000;
vi.spyOn(Date, "now").mockReturnValue(now);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,13 @@ export class KVNextModeTagCache implements NextModeTagCache {
const isStale = [...result.values()].some((v) => {
if (v == null) return false;
const stale = getStale(v);
if (stale == null || stale <= (lastModified ?? now)) return false;
const expire = getExpire(v);
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
// after the page was generated, preventing a stale signal from a previous ISR cycle.
const isInStaleWindow =
stale != null && getRevalidatedAt(v) > (lastModified ?? now) && (lastModified ?? now) <= stale;
if (!isInStaleWindow) return false;
return expire == null || expire > now;
});

Expand Down
Loading
Loading