From a0c98ebf2383f236273aaa3e5097d1ec559a6fc2 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:34:28 +0200 Subject: [PATCH 1/3] feat(cache): implement getTagCacheResult for cache validation and revalidation tracking --- packages/open-next/src/adapters/cache.ts | 30 ++++- .../src/adapters/composable-cache.ts | 34 ++++-- .../src/core/routing/cacheInterceptor.ts | 80 ++++++++++--- packages/open-next/src/types/overrides.ts | 11 ++ .../tests-unit/tests/adapters/cache.test.ts | 111 ++++++++++++++++++ .../tests/adapters/composable-cache.test.ts | 71 +++++++++++ .../core/routing/cacheInterceptor.test.ts | 99 ++++++++++++++++ 7 files changed, 412 insertions(+), 24 deletions(-) diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index b170b59b3..9c6cbf1e2 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -75,7 +75,14 @@ export default class Cache { ? false : await hasBeenRevalidated(key, _tags, cachedEntry); - if (_hasBeenRevalidated) return null; + if (_hasBeenRevalidated) { + await globalThis.incrementalCache.getTagCacheResult?.({ + key, + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + return null; + } // For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated // We only need to check for the path as it should already contain all the tags @@ -106,6 +113,12 @@ export default class Cache { ? false : await isStale(key, _tags, _lastModified); + await globalThis.incrementalCache.getTagCacheResult?.({ + key, + hasBeenRevalidated: false, + isStaleFromTag: _isStale, + }); + return { lastModified: _isStale ? 1 : _lastModified, value: cachedEntry.value, @@ -133,12 +146,25 @@ export default class Cache { const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache ? false : await hasBeenRevalidated(key, tags, cachedEntry); - if (_hasBeenRevalidated) return null; + if (_hasBeenRevalidated) { + await globalThis.incrementalCache.getTagCacheResult?.({ + key, + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + return null; + } const _isStale = cachedEntry.shouldBypassTagCache ? false : await isStale(key, tags, _lastModified); + await globalThis.incrementalCache.getTagCacheResult?.({ + key, + hasBeenRevalidated: false, + isStaleFromTag: _isStale, + }); + const store = globalThis.__openNextAls.getStore(); if (store) { store.lastModified = _isStale ? 1 : _lastModified; diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 90c4af653..dc90d1a43 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -39,18 +39,27 @@ export default { result.value.tags.length > 0 ) { // We need to check if the tags associated with this entry has been revalidated - const hasBeenRevalidated = result.shouldBypassTagCache + const _hasBeenRevalidated = result.shouldBypassTagCache ? false : await globalThis.tagCache.hasBeenRevalidated( result.value.tags, result.lastModified, ); - if (hasBeenRevalidated) return undefined; // Check if tags are stale – entry is valid but needs background revalidation - const isCacheStale = result.shouldBypassTagCache + const isCacheStale = _hasBeenRevalidated ? false - : await isStale(cacheKey, result.value.tags, result.lastModified); + : result.shouldBypassTagCache + ? false + : await isStale(cacheKey, result.value.tags, result.lastModified); + + await globalThis.incrementalCache.getTagCacheResult?.({ + key: cacheKey, + hasBeenRevalidated: _hasBeenRevalidated, + isStaleFromTag: isCacheStale, + }); + + if (_hasBeenRevalidated) return undefined; if (isCacheStale) { revalidate = -1; } @@ -58,18 +67,27 @@ export default { globalThis.tagCache.mode === "original" || globalThis.tagCache.mode === undefined ) { - const hasBeenRevalidated = result.shouldBypassTagCache + const _hasBeenRevalidated = result.shouldBypassTagCache ? false : (await globalThis.tagCache.getLastModified( cacheKey, result.lastModified, )) === -1; - if (hasBeenRevalidated) return undefined; // Check if tags are stale – entry is valid but needs background revalidation - const isCacheStale = result.shouldBypassTagCache + const isCacheStale = _hasBeenRevalidated ? false - : await isStale(cacheKey, result.value.tags, result.lastModified); + : result.shouldBypassTagCache + ? false + : await isStale(cacheKey, result.value.tags, result.lastModified); + + await globalThis.incrementalCache.getTagCacheResult?.({ + key: cacheKey, + hasBeenRevalidated: _hasBeenRevalidated, + isStaleFromTag: isCacheStale, + }); + + if (_hasBeenRevalidated) return undefined; if (isCacheStale) { revalidate = -1; } diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index eb93b17c2..75773f5d3 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -18,6 +18,47 @@ import { generateMessageGroupId } from "./queue"; const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; const CACHE_ONE_MONTH = 60 * 60 * 24 * 30; +/** + * Determines the effective revalidate duration for a given path/value combination, + * consulting the prerender manifest when `revalidate` is not explicitly set. + */ +function computeFinalRevalidate( + path: string, + revalidate?: number | false, +): number { + let finalRevalidate = CACHE_ONE_YEAR; + const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find( + (p) => p[0] === path, + )?.[1]; + if (revalidate === undefined && existingRoute) { + finalRevalidate = + existingRoute.initialRevalidateSeconds === false + ? CACHE_ONE_YEAR + : existingRoute.initialRevalidateSeconds; + // eslint-disable-next-line sonarjs/elseif-without-else + } else if (revalidate !== undefined) { + finalRevalidate = revalidate === false ? CACHE_ONE_YEAR : revalidate; + } + return finalRevalidate; +} + +/** + * Returns whether a cache entry is stale purely due to time (TTL expiry), + * independent of any tag-based revalidation. + */ +function computeIsStaleFromTime( + path: string, + revalidate: number | false | undefined, + lastModified: number | undefined, +): boolean { + const finalRevalidate = computeFinalRevalidate(path, revalidate); + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + if (isSSG) return false; + const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000); + const remainingTtl = Math.max(finalRevalidate - age, 1); + return remainingTtl === 1; +} + /* * We use this header to prevent Firefox (and possibly some CDNs) from incorrectly reusing the RSC responses during caching. * This can especially happen when there's a redirect in the middleware as the `_rsc` query parameter is not visible there. @@ -41,20 +82,7 @@ async function computeCacheControl( lastModified?: number, isStaleFromTagCache = false, ) { - let finalRevalidate = CACHE_ONE_YEAR; - - const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find( - (p) => p[0] === path, - )?.[1]; - if (revalidate === undefined && existingRoute) { - finalRevalidate = - existingRoute.initialRevalidateSeconds === false - ? CACHE_ONE_YEAR - : existingRoute.initialRevalidateSeconds; - // eslint-disable-next-line sonarjs/elseif-without-else - } else if (revalidate !== undefined) { - finalRevalidate = revalidate === false ? CACHE_ONE_YEAR : revalidate; - } + const finalRevalidate = computeFinalRevalidate(path, revalidate); // calculate age const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000); const hash = (str: string) => createHash("md5").update(str).digest("hex"); @@ -298,6 +326,17 @@ export async function cacheInterceptor( : await hasBeenRevalidated(cacheKey, tags, cachedData); if (_hasBeenRevalidated) { + const isStaleFromTime = computeIsStaleFromTime( + localizedPath, + cachedData.value.revalidate, + cachedData.lastModified, + ); + await globalThis.incrementalCache.getTagCacheResult?.({ + key: cacheKey, + hasBeenRevalidated: true, + isStaleFromTag: false, + isStaleFromTime, + }); return event; } } @@ -307,6 +346,19 @@ export async function cacheInterceptor( ? false : await isStale(cacheKey, tags, cachedData.lastModified ?? Date.now()); + const isStaleFromTime = computeIsStaleFromTime( + localizedPath, + cachedData.value.revalidate, + cachedData.lastModified, + ); + + await globalThis.incrementalCache.getTagCacheResult?.({ + key: cacheKey, + hasBeenRevalidated: false, + isStaleFromTag: _isStale, + isStaleFromTime, + }); + const host = event.headers.host; switch (cachedData?.value?.type) { case "app": diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 5d772d967..7d7957924 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -127,6 +127,17 @@ export type IncrementalCache = { ): Promise; delete(key: string): Promise; name: string; + /** + * Optional observer hook called after the tag cache has been consulted. + * Receives the combined tag-cache verdict for the given cache key. + * `isStaleFromTime` is only provided by the cache interceptor. + */ + getTagCacheResult?: (params: { + key: string; + hasBeenRevalidated: boolean; + isStaleFromTag: boolean; + isStaleFromTime?: boolean; + }) => Promise; }; // Tag cache diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 69e054694..cd0425e8d 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -28,6 +28,7 @@ describe("CacheHandler", () => { }), set: vi.fn(), delete: vi.fn(), + getTagCacheResult: vi.fn().mockResolvedValue(undefined), }; globalThis.incrementalCache = incrementalCache; @@ -255,6 +256,57 @@ describe("CacheHandler", () => { expect(tagCache.isStale).not.toHaveBeenCalled(); }); }); + + describe("getTagCacheResult", () => { + it("Should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false on cache hit", async () => { + await cache.get("key", { fetchCache: true }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: false, + isStaleFromTag: false, + }); + }); + + it("Should call getTagCacheResult with hasBeenRevalidated: true when fetch cache is expired", async () => { + tagCache.getLastModified.mockResolvedValueOnce(-1); + + await cache.get("key", { fetchCache: true }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + }); + + it("Should call getTagCacheResult with isStaleFromTag: true when fetch cache tag is stale (next16)", async () => { + globalThis.nextVersion = "16.0.0"; + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(true); + incrementalCache.get.mockResolvedValueOnce({ + value: { + kind: "FETCH", + data: { + headers: {}, + body: "{}", + url: "https://example.com", + status: 200, + }, + }, + lastModified: Date.now(), + }); + + await cache.get("key", { kind: "FETCH", tags: ["tag1"] }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: false, + isStaleFromTag: true, + }); + }); + }); }); describe("incremental cache", () => { @@ -536,6 +588,65 @@ describe("CacheHandler", () => { expect(tagCache.isStale).not.toHaveBeenCalled(); }); }); + + describe("getTagCacheResult", () => { + it("Should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false on cache hit", async () => { + await cache.get("key", { kindHint: "app" }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: false, + isStaleFromTag: false, + }); + }); + + it("Should call getTagCacheResult with hasBeenRevalidated: true when cache is expired", async () => { + tagCache.getLastModified.mockResolvedValueOnce(-1); + incrementalCache.get.mockResolvedValueOnce({ + value: { type: "route", body: "{}" }, + lastModified: Date.now(), + }); + + await cache.get("key", { kindHint: "app" }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + }); + + it("Should call getTagCacheResult with isStaleFromTag: true when cache tag is stale (next16)", async () => { + globalThis.nextVersion = "16.0.0"; + tagCache.isStale.mockResolvedValueOnce(true); + incrementalCache.get.mockResolvedValueOnce({ + value: { type: "route", body: "{}" }, + lastModified: Date.now(), + }); + + await cache.get("key", { kindHint: "app" }); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "key", + hasBeenRevalidated: false, + isStaleFromTag: true, + }); + }); + + it("Should not throw when getTagCacheResult is not defined", async () => { + const prevIncrementalCache = globalThis.incrementalCache; + globalThis.incrementalCache = { + ...prevIncrementalCache, + getTagCacheResult: undefined, + }; + + await expect( + cache.get("key", { kindHint: "app" }), + ).resolves.not.toThrow(); + + globalThis.incrementalCache = prevIncrementalCache; + }); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 2dbc8a34d..eb604e1a9 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -25,6 +25,7 @@ describe("Composable cache handler", () => { }), set: vi.fn(), delete: vi.fn(), + getTagCacheResult: vi.fn().mockResolvedValue(undefined), }; globalThis.incrementalCache = incrementalCache; @@ -246,6 +247,76 @@ describe("Composable cache handler", () => { expect(tagCache.isStale).not.toHaveBeenCalled(); }); + describe("getTagCacheResult", () => { + it("should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false in nextMode", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(false); + + await ComposableCache.get("test-key"); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "test-key", + hasBeenRevalidated: false, + isStaleFromTag: false, + }); + }); + + it("should call getTagCacheResult with hasBeenRevalidated: true in nextMode when cache is expired", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); + + await ComposableCache.get("test-key"); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "test-key", + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + }); + + it("should call getTagCacheResult with isStaleFromTag: true in nextMode when cache is stale", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(true); + + await ComposableCache.get("test-key"); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "test-key", + hasBeenRevalidated: false, + isStaleFromTag: true, + }); + }); + + it("should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false in original mode", async () => { + tagCache.mode = "original"; + tagCache.getLastModified.mockResolvedValueOnce(Date.now()); + tagCache.isStale.mockResolvedValueOnce(false); + + await ComposableCache.get("test-key"); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "test-key", + hasBeenRevalidated: false, + isStaleFromTag: false, + }); + }); + + it("should call getTagCacheResult with hasBeenRevalidated: true in original mode when cache is expired", async () => { + tagCache.mode = "original"; + tagCache.getLastModified.mockResolvedValueOnce(-1); + + await ComposableCache.get("test-key"); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "test-key", + hasBeenRevalidated: true, + isStaleFromTag: false, + }); + }); + }); + it("should return pending write promise if available", async () => { const pendingEntry = Promise.resolve({ value: toReadableStream("pending-value"), diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index b87461299..35e336c14 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -60,6 +60,7 @@ const incrementalCache = { get: vi.fn(), set: vi.fn(), delete: vi.fn(), + getTagCacheResult: vi.fn().mockResolvedValue(undefined), }; const tagCache = { @@ -850,4 +851,102 @@ describe("cacheInterceptor", () => { expect(queue.send).not.toHaveBeenCalled(); }); }); + + describe("getTagCacheResult", () => { + it("should call getTagCacheResult with isStaleFromTime: false on a fresh SSG cache hit", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + revalidate: false, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + + await cacheInterceptor(event); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "/albums", + hasBeenRevalidated: false, + isStaleFromTag: false, + isStaleFromTime: false, + }); + }); + + it("should call getTagCacheResult with isStaleFromTime: true when entry is time-stale", async () => { + const event = createEvent({ url: "/revalidate" }); + // /revalidate has initialRevalidateSeconds: 60; lastModified is 2 minutes ago + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + revalidate: 60, + }, + lastModified: new Date("2024-01-01T23:58:00Z").getTime(), + }); + + await cacheInterceptor(event); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({ + key: "/revalidate", + hasBeenRevalidated: false, + isStaleFromTag: false, + isStaleFromTime: true, + }); + }); + + it("should call getTagCacheResult with isStaleFromTag: true when tag cache reports stale", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + revalidate: false, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + tagCache.isStale.mockResolvedValueOnce(true); + + await cacheInterceptor(event); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith( + expect.objectContaining({ + key: "/albums", + isStaleFromTag: true, + }), + ); + }); + + it("should call getTagCacheResult with hasBeenRevalidated: true when app cache has been revalidated", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + }); + tagCache.getLastModified.mockResolvedValueOnce(-1); + + await cacheInterceptor(event); + + expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith( + expect.objectContaining({ + key: "/albums", + hasBeenRevalidated: true, + isStaleFromTag: false, + }), + ); + }); + + it("should not call getTagCacheResult when request is bypassed by next-action header", async () => { + const event = createEvent({ + headers: { "next-action": "something" }, + }); + + await cacheInterceptor(event); + + expect(incrementalCache.getTagCacheResult).not.toHaveBeenCalled(); + }); + }); }); From cc239ccc48688a9a0b07588c47ef9e65523924c1 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:36:18 +0200 Subject: [PATCH 2/3] changeset --- .changeset/thin-oranges-serve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thin-oranges-serve.md diff --git a/.changeset/thin-oranges-serve.md b/.changeset/thin-oranges-serve.md new file mode 100644 index 000000000..8b96014eb --- /dev/null +++ b/.changeset/thin-oranges-serve.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": minor +--- + +Add a getTagCacheResult method for the incremental cache From ddab1e5b309180df400ee73f4a4f3d0dd132b64f Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 8 Jul 2026 21:45:20 +0200 Subject: [PATCH 3/3] don't delete cache tags from headers --- packages/open-next/src/utils/cache.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/open-next/src/utils/cache.ts b/packages/open-next/src/utils/cache.ts index b8f861f1c..de51411d6 100644 --- a/packages/open-next/src/utils/cache.ts +++ b/packages/open-next/src/utils/cache.ts @@ -83,7 +83,6 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { try { const cacheTags = value.meta?.headers?.["x-next-cache-tags"]?.split(",") ?? []; - delete value.meta?.headers?.["x-next-cache-tags"]; return cacheTags; } catch (e) { return [];