Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
13 changes: 13 additions & 0 deletions .changeset/slick-crabs-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@opennextjs/aws": minor
---

Add support for stale-while-revalidate in revalidateTag
Comment thread
conico974 marked this conversation as resolved.
Outdated

Introduces a new methods in the tag cache (hasBeenStale, optional), both for the original and the next mode tag cache, mandatory to make swr work.
Comment thread
conico974 marked this conversation as resolved.
Outdated

It also introduces a RequestCache utils that can be used to cache things scoped to a request (stored in the OpenNext internal AsyncLocalStorage context)
Comment thread
conico974 marked this conversation as resolved.
Outdated

### 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.
Comment thread
conico974 marked this conversation as resolved.
Outdated
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>
);
}
94 changes: 80 additions & 14 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type {
IncrementalCacheContext,
IncrementalCacheValue,
} from "types/cache";
import { getTagsFromValue, hasBeenRevalidated, writeTags } from "utils/cache";
import {
getTagsFromValue,
hasBeenRevalidated,
hasBeenStale,
writeTags,
} from "utils/cache";
import { isBinaryContentType } from "../utils/binary";
import { debug, error, warn } from "./logger";

Expand Down Expand Up @@ -96,8 +101,12 @@ export default class Cache {
}
}

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

return {
lastModified: _lastModified,
lastModified: _isStale ? 1 : _lastModified,
value: cachedEntry.value,
} as CacheHandlerValue;
} catch (e) {
Expand All @@ -119,15 +128,20 @@ 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 hasBeenStale(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") {
Expand Down Expand Up @@ -333,7 +347,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 +364,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,
expiry:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Default behavior: immediate expiration
Comment thread
conico974 marked this conversation as resolved.
Outdated
return {
tag,
expiry: 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 +410,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,
expiry:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Default behavior: immediate expiration
return {
...baseEntry,
expiry: 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 +444,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,
expiry:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
return {
...baseEntry,
expiry: now,
};
}),
);
}
}
Expand Down
96 changes: 94 additions & 2 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 { hasBeenStale, writeTags } from "utils/cache";
import { fromReadableStream, toReadableStream } from "utils/stream";
import { debug } from "./logger";

Expand Down Expand Up @@ -34,6 +34,7 @@ export default {
debug("composable cache result", result);

// We need to check if the tags associated with this entry has been revalidated
Comment thread
conico974 marked this conversation as resolved.
Outdated
let revalidate = result.value.revalidate;
if (
globalThis.tagCache.mode === "nextMode" &&
result.value.tags.length > 0
Expand All @@ -45,6 +46,18 @@ export default {
result.lastModified,
);
if (hasBeenRevalidated) return undefined;

// Check if tags are stale – entry is valid but needs background revalidation
const isStale = result.shouldBypassTagCache
? false
: await hasBeenStale(
cacheKey,
result.value.tags,
result.lastModified,
);
if (isStale) {
revalidate = -1;
}
} else if (
globalThis.tagCache.mode === "original" ||
globalThis.tagCache.mode === undefined
Expand All @@ -56,10 +69,23 @@ export default {
result.lastModified,
)) === -1;
if (hasBeenRevalidated) return undefined;

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

return {
...result.value,
revalidate,
value: toReadableStream(result.value.value),
};
} catch (e) {
Expand Down Expand Up @@ -149,4 +175,70 @@ 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).
*/
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,
expiry:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
// Default: immediate expiry, no grace period
return { tag, expiry: 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,
expiry:
durations.expire !== undefined
? now + durations.expire * 1000
: undefined,
};
}
return { path, tag, expiry: now };
});
}),
);
const toWrite = pathsPerTag.flat();
if (toWrite.length > 0) {
await writeTags(toWrite);
}
}
} catch (e) {
debug("Failed to update tags", e);
}
},
} satisfies ComposableCacheHandler;
8 changes: 8 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,12 @@ type DataType = {
revalidatedAt: {
N: string;
};
stale?: {
N: string;
};
expiry?: {
N: string;
};
Comment thread
conico974 marked this conversation as resolved.
};

export interface InitializationFunctionEvent {
Expand Down Expand Up @@ -63,6 +69,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.expiry && { expiry: Number.parseInt(item.expiry.N) }),
}));

await tagCache.writeTags(parsedData);
Expand Down
8 changes: 8 additions & 0 deletions packages/open-next/src/build/compileCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export function compileCache(
"15.0.0",
);

const isAfter16 = buildHelper.compareSemver(
options.nextVersion,
">=",
"16.0.0",
);

// Normal cache
buildHelper.esbuildSync(
{
Expand All @@ -40,6 +46,7 @@ export function compileCache(
config.dangerous?.disableTagCache ?? false
};`,
`globalThis.isNextAfter15 = ${isAfter15};`,
Comment thread
conico974 marked this conversation as resolved.
`globalThis.isNextAfter16 = ${isAfter16};`,
].join(""),
},
},
Expand Down Expand Up @@ -70,6 +77,7 @@ export function compileCache(
config.dangerous?.disableTagCache ?? false
};`,
`globalThis.isNextAfter15 = ${isAfter15};`,
`globalThis.isNextAfter16 = ${isAfter16};`,
].join(""),
},
},
Expand Down
Loading
Loading