Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
45f9461
feat(tagCache): add support for stale and expiry properties in Dynamo…
conico974 Mar 15, 2026
8c2e0a2
feat(tagCache): enhance revalidation logic with expiry and stale prop…
conico974 Mar 15, 2026
0640fb4
feat(tagCache): implement hasBeenStale method for tag revalidation an…
conico974 Mar 15, 2026
ea56122
add test for swr with revalidateTag
conico974 Mar 15, 2026
12f594c
linting
conico974 Mar 15, 2026
2c04613
feat(cache): implement stale tag handling in Cache class and update l…
conico974 Mar 22, 2026
09348a7
linting
conico974 Mar 22, 2026
ea37677
feat(patchNextServer): add rule to provide internal waitUntil functio…
conico974 Mar 22, 2026
cc67c92
feat(cache): refactor hasBeenStale logic and update related interface…
conico974 Mar 28, 2026
74c17a1
linting
conico974 Mar 28, 2026
4485b3f
Introduce request cache
conico974 Mar 29, 2026
6bee697
feat(cache): implement request caching for DynamoDB tag retrieval and…
conico974 Mar 29, 2026
ba75736
feat(tests): add unit tests for dynamodb tagCache functionality
conico974 Mar 29, 2026
ca24a37
fix unit test
conico974 Mar 29, 2026
dc62639
linting
conico974 Mar 29, 2026
7390f29
changeset
conico974 Mar 29, 2026
d23a850
update changeset for the breaking change
conico974 Mar 29, 2026
a0bffd9
review
conico974 Apr 3, 2026
1c230e4
refactor: rename hasBeenStale to isStale for consistency in cache han…
conico974 Apr 3, 2026
4419b31
refactor: replace 'expiry' with 'expire' for consistency across cache…
conico974 Apr 3, 2026
9392acd
refactor: replace version checks with compareSemver utility for consi…
conico974 Apr 3, 2026
fdaf39e
refactor: enhance type safety in request cache and improve tag cache …
conico974 Apr 3, 2026
b1271fb
linting
conico974 Apr 8, 2026
d552686
review
conico974 Apr 8, 2026
594a00a
rename hasBeenStale to isStale
conico974 Apr 8, 2026
d92673b
linting
conico974 Apr 8, 2026
185e81a
Merge remote-tracking branch 'origin/main' into conico/swr-tag
conico974 Apr 8, 2026
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
16 changes: 16 additions & 0 deletions .changeset/slick-crabs-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@opennextjs/aws": minor
---

Add support for SWR (stale-while-revalidate) in `revalidateTag`

Introduces a new optional method `isStale` in the tag cache for both the original and the next modes. The implementation is mandatory for SWR to work.

It also introduces a `RequestCache` utility that can be used to cache things scoped to a request (stored in the OpenNext internal AsyncLocalStorage context)

### BREAKING CHANGE

`writeTags` for the tag cache signature has changed to `writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>` for Next mode, and `writeTags(tags: OriginalTagCacheWriteInput[]): Promise<void>` for the original mode.
This is breaking only for custom tag cache implementations, if you are using the default one provided by OpenNext, you don't need to do anything.

`globalThis.isNextAfter15` is no longer available in the cache.
10 changes: 10 additions & 0 deletions examples/app-router/app/api/revalidate-tag-stale/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { revalidateTag } from "next/cache";

export const dynamic = "force-dynamic";

export async function GET() {
// Revalidate with expire:10 to mark the tag as stale immediately and set expiry to 10 seconds later
revalidateTag("revalidate-stale", { expire: 10 });

return new Response("ok");
}
20 changes: 20 additions & 0 deletions examples/app-router/app/revalidate-tag/stale/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { unstable_cache } from "next/cache";

const getCachedTime = unstable_cache(
async () => new Date().toISOString(),
["stale-revalidate-time"],
{
tags: ["revalidate-stale"],
// Long revalidate time so the cache only expires via revalidateTag
revalidate: 3600,
},
);

export default async function StaleRevalidateTag() {
const cachedTime = await getCachedTime();
return (
<div>
<p data-testid="cached-time">Cached time: {cachedTime}</p>
</div>
);
}
108 changes: 91 additions & 17 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import type {
IncrementalCacheContext,
IncrementalCacheValue,
} from "types/cache";
import { getTagsFromValue, hasBeenRevalidated, writeTags } from "utils/cache";
import {
getTagsFromValue,
hasBeenRevalidated,
isStale,
writeTags,
} from "utils/cache";
import { isBinaryContentType } from "../utils/binary";
import { compareSemver } from "../utils/semver";
import { debug, error, warn } from "./logger";

export const SOFT_TAG_PREFIX = "_N_T_/";
Expand Down Expand Up @@ -96,8 +102,12 @@ export default class Cache {
}
}

const _isStale = cachedEntry.shouldBypassTagCache
Comment thread
conico974 marked this conversation as resolved.
? false
: await isStale(key, _tags, _lastModified);

return {
lastModified: _lastModified,
lastModified: _isStale ? 1 : _lastModified,
value: cachedEntry.value,
} as CacheHandlerValue;
} catch (e) {
Expand All @@ -119,22 +129,29 @@ export default class Cache {

const meta = cacheData.meta;
const tags = getTagsFromValue(cacheData);
const _lastModified = cachedEntry.lastModified ?? Date.now();
let _lastModified = cachedEntry.lastModified ?? Date.now();
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
? false
: await hasBeenRevalidated(key, tags, cachedEntry);
if (_hasBeenRevalidated) return null;

const _isStale = cachedEntry.shouldBypassTagCache
? false
: await isStale(key, tags, _lastModified);

const store = globalThis.__openNextAls.getStore();
if (store) {
store.lastModified = _lastModified;
store.lastModified = _isStale ? 1 : _lastModified;
_lastModified = store.lastModified;
}

if (cacheData?.type === "route") {
return {
lastModified: _lastModified,
value: {
kind: globalThis.isNextAfter15 ? "APP_ROUTE" : "ROUTE",
kind: compareSemver(globalThis.nextVersion, ">=", "15.0.0")
? "APP_ROUTE"
: "ROUTE",
body: Buffer.from(
cacheData.body ?? Buffer.alloc(0),
isBinaryContentType(String(meta?.headers?.["content-type"]))
Expand All @@ -147,7 +164,10 @@ export default class Cache {
} as CacheHandlerValue;
}
if (cacheData?.type === "page" || cacheData?.type === "app") {
if (globalThis.isNextAfter15 && cacheData?.type === "app") {
if (
compareSemver(globalThis.nextVersion, ">=", "15.0.0") &&
cacheData?.type === "app"
) {
const segmentData = new Map<string, Buffer>();
if (cacheData.segmentData) {
for (const [segmentPath, segmentContent] of Object.entries(
Expand All @@ -172,7 +192,9 @@ export default class Cache {
return {
lastModified: _lastModified,
value: {
kind: globalThis.isNextAfter15 ? "PAGES" : "PAGE",
kind: compareSemver(globalThis.nextVersion, ">=", "15.0.0")
? "PAGES"
: "PAGE",
html: cacheData.html,
pageData:
cacheData.type === "page" ? cacheData.json : cacheData.rsc,
Expand Down Expand Up @@ -333,7 +355,10 @@ export default class Cache {
}
}

public async revalidateTag(tags: string | string[]) {
public async revalidateTag(
tags: string | string[],
durations?: { expire?: number },
) {
const config = globalThis.openNextConfig.dangerous;
if (config?.disableTagCache || config?.disableIncrementalCache) {
return;
Expand All @@ -347,7 +372,27 @@ export default class Cache {
if (globalThis.tagCache.mode === "nextMode") {
const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? [];

await writeTags(_tags);
const now = Date.now();
const tagsToWrite = _tags.map((tag) => {
if (durations) {
// Use provided durations
return {
tag,
stale: now,
expire:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Immediate expiration, default behavior before next 16, now only with {expire: 0}
return {
tag,
expire: now,
};
});

await writeTags(tagsToWrite);
if (paths.length > 0) {
// TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it
// It also means that we'll need to provide the tags used in every request to the wrapper or converter.
Expand All @@ -373,10 +418,26 @@ export default class Cache {
// Find all keys with the given tag
const paths = await globalThis.tagCache.getByTag(tag);
debug("Items", paths);
const toInsert = paths.map((path) => ({
path,
tag,
}));
const now = Date.now();
const toInsert = paths.map((path) => {
const baseEntry = { path, tag };
if (durations) {
// Use provided durations
return {
...baseEntry,
stale: now,
expire:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Default behavior: immediate expiration
return {
...baseEntry,
expire: now,
};
});

// If the tag is a soft tag, we should also revalidate the hard tags
if (tag.startsWith(SOFT_TAG_PREFIX)) {
Expand All @@ -391,10 +452,23 @@ export default class Cache {
const _paths = await globalThis.tagCache.getByTag(hardTag);
debug({ hardTag, _paths });
toInsert.push(
..._paths.map((path) => ({
path,
tag: hardTag,
})),
..._paths.map((path) => {
const baseEntry = { path, tag: hardTag };
if (durations) {
return {
...baseEntry,
stale: now,
expire:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
return {
...baseEntry,
expire: now,
};
}),
);
}
}
Expand Down
91 changes: 88 additions & 3 deletions packages/open-next/src/adapters/composable-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache";
import type { CacheValue } from "types/overrides";
import { writeTags } from "utils/cache";
import type { CacheValue, OriginalTagCache } from "types/overrides";
import { isStale, writeTags } from "utils/cache";
import { fromReadableStream, toReadableStream } from "utils/stream";
import { debug } from "./logger";

Expand Down Expand Up @@ -33,18 +33,27 @@ export default {

debug("composable cache result", result);

// We need to check if the tags associated with this entry has been revalidated
let revalidate = result.value.revalidate;
if (
globalThis.tagCache.mode === "nextMode" &&
result.value.tags.length > 0
) {
// We need to check if the tags associated with this entry has been revalidated
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
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);
if (isCacheStale) {
revalidate = -1;
}
} else if (
globalThis.tagCache.mode === "original" ||
globalThis.tagCache.mode === undefined
Expand All @@ -56,10 +65,19 @@ export default {
result.lastModified,
)) === -1;
if (hasBeenRevalidated) return undefined;

// Check if tags are stale – entry is valid but needs background revalidation
const isCacheStale = result.shouldBypassTagCache
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);
if (isCacheStale) {
revalidate = -1;
}
}

return {
...result.value,
revalidate,
value: toReadableStream(result.value.value),
};
} catch (e) {
Expand Down Expand Up @@ -149,4 +167,71 @@ export default {
// This function does absolutely nothing
return;
},

/**
* Added in Next.js 16. Updates tags with optional stale/expire durations.
* Mirrors the logic in `Cache.revalidateTag` but without CDN invalidation
* since composable cache keys are not URL paths.
*
* When `durations` is provided, marks tags as stale immediately and optionally
Comment thread
conico974 marked this conversation as resolved.
* sets an expiry timestamp. When omitted, immediately expires tags (no grace period).
* durations.expire is in seconds, but we convert it to milliseconds for storage and comparison.
*/
async updateTags(tags: string[], durations?: { expire?: number }) {
const config = globalThis.openNextConfig.dangerous;
if (config?.disableTagCache || config?.disableIncrementalCache) {
return;
}
if (tags.length === 0) {
return;
}
try {
const now = Date.now();
if (globalThis.tagCache.mode === "nextMode") {
const tagsToWrite = tags.map((tag) => {
if (durations) {
return {
tag,
stale: now,
expire:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Default: immediate expiry, no grace period
return { tag, expire: now };
});
await writeTags(tagsToWrite);
} else {
// Original mode: resolve tag → path mappings first
const originalTagCache = globalThis.tagCache as OriginalTagCache;
const pathsPerTag = await Promise.all(
tags.map(async (tag) => {
const paths = await originalTagCache.getByTag(tag);
return paths.map((path: string) => {
if (durations) {
return {
path,
tag,
stale: now,
expire:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
return { path, tag, expire: now };
});
}),
);
const toWrite = pathsPerTag.flat();
if (toWrite.length > 0) {
await writeTags(toWrite);
}
}
} catch (e) {
debug("Failed to update tags", e);
}
},
} satisfies ComposableCacheHandler;
14 changes: 14 additions & 0 deletions packages/open-next/src/adapters/dynamo-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ type DataType = {
revalidatedAt: {
N: string;
};
/**
* The time at which the tag should be considered stale, in milliseconds since epoch.
*/
stale?: {
N: string;
};
/**
* The time at which the tag should expire, in milliseconds since epoch.
*/
expire?: {
N: string;
};
Comment thread
conico974 marked this conversation as resolved.
};

export interface InitializationFunctionEvent {
Expand Down Expand Up @@ -63,6 +75,8 @@ async function insert(
tag: item.tag.S,
path: item.path.S,
revalidatedAt: Number.parseInt(item.revalidatedAt.N),
...(item.stale && { stale: Number.parseInt(item.stale.N) }),
...(item.expire && { expire: Number.parseInt(item.expire.N) }),
}));

await tagCache.writeTags(parsedData);
Expand Down
Loading
Loading