Conversation
…h revalidateTag Ported from opennextjs/opennextjs-aws#1122 Adds stale-while-revalidate (SWR) support for revalidateTag in Next.js 16+, including isStale methods on tag caches, a per-request RequestCache utility, runtime nextVersion for version checks, updateTags on composable cache handler, and the provideInternalWaitUntil patch. Changeset: .changeset/port-pr-1122.md
opennextjs/opennextjs-aws#1142 Changeset: .changeset/port-pr-1142.md
Add support for SWR (stale-while-revalidate) in revalidateTag opennextjs/opennextjs-cloudflare#1168 Changeset: .changeset/port-pr-1168.md
opennextjs/opennextjs-cloudflare#1200 Changeset: .changeset/port-pr-1200.md
opennextjs/opennextjs-cloudflare#1193 Changeset: .changeset/port-pr-1193.md
commit: |
| const compute = (item: any): boolean => { | ||
| if (!item) return false; | ||
| if (item.expire?.N) { | ||
| const expiry = Number.parseInt(item.expire.N); | ||
| if (expiry <= now && expiry > (lastModified ?? 0)) return true; | ||
| } | ||
| return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0); | ||
| }; |
There was a problem hiding this comment.
🔴 dynamodb-nextMode hasBeenRevalidated falls through to revalidatedAt check for SWR-window tags, defeating stale-while-revalidate
In dynamodb-nextMode.ts, the compute function inside hasBeenRevalidated does not return false when a tag has expire set but not yet expired (SWR window). The if (item.expire?.N) { if (expiry <= now ...) return true; } block only handles the expired case — when expire > now (SWR window), it falls through to return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0), which returns true, fully invalidating the cache entry.
All the Cloudflare implementations (d1-next-tag-cache.ts:49, kv-next-tag-cache.ts:114, do-sharded-tag-cache.ts:166) use if (expire != null) return ...; return revalidatedAt > ...; — when expire is set, they always return based on the expire check only. This ensures SWR-window tags return false from hasBeenRevalidated, allowing isStale() to handle them. The DynamoDB implementation misses this pattern, so the SWR feature is effectively broken for this tag cache backend.
| const compute = (item: any): boolean => { | |
| if (!item) return false; | |
| if (item.expire?.N) { | |
| const expiry = Number.parseInt(item.expire.N); | |
| if (expiry <= now && expiry > (lastModified ?? 0)) return true; | |
| } | |
| return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0); | |
| }; | |
| const compute = (item: any): boolean => { | |
| if (!item) return false; | |
| if (item.expire?.N) { | |
| const expiry = Number.parseInt(item.expire.N); | |
| return expiry <= now && expiry > (lastModified ?? 0); | |
| } | |
| return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0); | |
| }; | |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | ||
| if (item.expire?.N) { | ||
| return Number.parseInt(item.expire.N) > now; | ||
| } | ||
| return true; | ||
| }); |
There was a problem hiding this comment.
🔴 dynamodb (full SDK) getLastModified includes SWR-window tags in nonExpiredRevalidatedTags, causing full invalidation during SWR window
In dynamodb.ts, the nonExpiredRevalidatedTags filter keeps tags where expire > now (i.e., tags still in the SWR window). Since nonExpiredRevalidatedTags.length > 0 causes getLastModified to return -1, any cache entry associated with an SWR-revalidated tag is fully invalidated during the SWR window instead of being served stale.
The correct behavior (matching dynamodb-lite.ts:202-204) is to exclude all tags that have an expire field set but haven't fully expired yet, since expired tags are already handled by hasExpiredTag, and SWR-window tags should be handled by isStale() instead.
Comparison with dynamodb-lite.ts
The dynamodb-lite.ts version filters differently:
return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt.N);
This effectively excludes SWR-window tags (where expire ≠ revalidatedAt), though this approach is fragile. A cleaner fix is to exclude all tags with an expire field from the non-expired count.
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | |
| if (item.expire?.N) { | |
| return Number.parseInt(item.expire.N) > now; | |
| } | |
| return true; | |
| }); | |
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | |
| if (item.expire?.N) { | |
| // Tags with expire are either: | |
| // - expired (expire <= now): already handled by hasExpiredTag | |
| // - in SWR window (expire > now): handled by isStale() | |
| return false; | |
| } | |
| return true; | |
| }); | |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
This PR ports multiple upstream SWR-related changes, adding stale-while-revalidate support to tag-based revalidation across the AWS and Cloudflare runtimes, along with per-request caching to reduce repeated tag-cache lookups and new tests to validate the behavior.
Changes:
- Introduces SWR-aware tag invalidation metadata (
stale/expire) and anisStale()path across TagCache implementations. - Adds per-request in-memory caching (
RequestCache) to deduplicate tag-cache reads within a request. - Updates cache adapters/interceptor and patches to support Next 16+ behavior (including internal
waitUntil) and expands unit tests.
Reviewed changes
Copilot reviewed 44 out of 45 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Updates lockfile to include new AWS SDK dependency resolution. |
| packages/tests-unit/tests/overrides/tagCache/dynamodb.test.ts | Adds unit tests for DynamoDB (original mode) tag cache behavior/caching. |
| packages/tests-unit/tests/overrides/tagCache/dynamodb-nextMode.test.ts | Adds unit tests for DynamoDB nextMode tag cache SWR behavior. |
| packages/tests-unit/tests/overrides/tagCache/dynamodb-lite.test.ts | Adds unit tests for DynamoDB-lite tag cache SWR behavior/caching. |
| packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts | Adds tests for stale serving behavior via tagCache.isStale. |
| packages/tests-unit/tests/build/patch/patches/patchNextServer.test.ts | Tests new NextServer patch for internal waitUntil. |
| packages/tests-unit/tests/adapters/composable-cache.test.ts | Adds tests for stale behavior + updateTags in composable cache. |
| packages/tests-unit/tests/adapters/cache.test.ts | Updates cache adapter tests for SWR tag behavior and API changes. |
| packages/tests-unit/package.json | Adds @aws-sdk/client-dynamodb dev dependency for unit tests. |
| packages/open-next/src/utils/semver.ts | Adds shared semver comparison helper. |
| packages/open-next/src/utils/requestCache.ts | Introduces per-request RequestCache. |
| packages/open-next/src/utils/promise.ts | Adds requestCache to the request ALS context. |
| packages/open-next/src/utils/cache.ts | Adds isStale() helper + extends tag write dedupe keys for new inputs. |
| packages/open-next/src/types/overrides.ts | Extends TagCache types for SWR (stale/expire) + isStale. |
| packages/open-next/src/types/global.ts | Adds requestCache to ALS context and declares nextVersion. |
| packages/open-next/src/types/cache.ts | Extends tag-cache meta types and composable cache handler API (updateTags). |
| packages/open-next/src/overrides/tagCache/fs-dev.ts | Adds SWR fields + isStale to fs-dev original tag cache. |
| packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts | Adds SWR fields + isStale to fs-dev nextMode tag cache. |
| packages/open-next/src/overrides/tagCache/dynamodb.ts | Adds SWR fields + requestCache usage + isStale to DynamoDB tag cache. |
| packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts | Adds SWR fields + requestCache usage + isStale for DynamoDB nextMode. |
| packages/open-next/src/overrides/tagCache/dynamodb-lite.ts | Adds SWR fields + requestCache usage + isStale for DynamoDB-lite. |
| packages/open-next/src/overrides/tagCache/dummy.ts | Adds isStale stub to dummy tag cache. |
| packages/open-next/src/core/routing/cacheInterceptor.ts | Uses isStale() to serve stale and trigger background revalidation. |
| packages/open-next/src/build/patch/patches/patchNextServer.ts | Adds patch rule to provide internal waitUntil for Next 16+. |
| packages/open-next/src/build/helper.ts | Re-exports semver helper from new shared module. |
| packages/open-next/src/build/compileCache.ts | Injects globalThis.nextVersion into cache bundles. |
| packages/open-next/src/adapters/dynamo-provider.ts | Supports parsing/passing SWR fields when inserting into tag cache. |
| packages/open-next/src/adapters/composable-cache.ts | Adds staleness handling + implements updateTags. |
| packages/open-next/src/adapters/cache.ts | Adds staleness handling + extends revalidateTag with durations. |
| packages/cloudflare/src/cli/commands/populate-cache.ts | Updates D1 schema creation/migration for SWR fields. |
| packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts | Updates tag filtering to support new write input shape + isStale. |
| packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts | Adds SWR storage format + requestCache usage + isStale for KV. |
| packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts | Adds/updates KV nextMode SWR tests. |
| packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts | Adds SWR TagData + requestCache usage + isStale for sharded DO cache. |
| packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts | Updates sharded DO tag cache tests for new SWR logic and APIs. |
| packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts | Adds SWR fields + requestCache usage + isStale for D1 tag cache. |
| packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts | Adds/updates D1 nextMode SWR tests, incl. requestCache coverage. |
| packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts | Adjusts defaults/notes for bypass behavior with Next 16+ SWR. |
| packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts | Adds SWR fields + schema migration + new TagData API for DO. |
| packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts | Adds/updates DO sharded tag cache tests for SWR fields. |
| .changeset/port-pr-1200.md | Changeset for Cloudflare package port. |
| .changeset/port-pr-1193.md | Changeset for Cloudflare package port + stale logic fixes. |
| .changeset/port-pr-1168.md | Changeset for Cloudflare package SWR support. |
| .changeset/port-pr-1142.md | Changeset for AWS package cache stale logic enhancements. |
| .changeset/port-pr-1122.md | Changeset for AWS package SWR support. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function isStale(key: string, tags: string[], lastModified?: number): Promise<boolean> { | ||
| // SWR for revalidateTag has been implemented starting from Next.js 16 | ||
| if (!compareSemver(globalThis.nextVersion, ">=", "16.0.0")) { | ||
| return false; |
| if (!item) return false; | ||
| if (item.expire?.N) { | ||
| const expiry = Number.parseInt(item.expire.N); | ||
| if (expiry <= now && expiry > (lastModified ?? 0)) return true; |
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | ||
| if (item.expire?.N) { | ||
| return Number.parseInt(item.expire.N) > now; | ||
| } | ||
| return true; |
| itemsCache?.set(cacheKey, items); | ||
| } | ||
| debug("isStale items", key, items); | ||
| return items.length > 0; | ||
| } catch (e) { |
| items = ((await result.json()) as any).Items ?? []; | ||
| itemsCache?.set(cacheKey, items); | ||
| } | ||
| debug("isStale items", key, items); | ||
| return items.length > 0; | ||
| } catch (e) { |
| const nonExpiredRevalidatedTags = tags.filter( | ||
| (tagPathMapping) => | ||
| tagPathMapping.path.S === buildKey(path) && | ||
| Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) | ||
| Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) && | ||
| (!tagPathMapping.expire?.N || Number.parseInt(tagPathMapping.expire.N) > now) | ||
| ); | ||
| return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); | ||
|
|
||
| return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); |
| const result = await this.#resolveTagValues(tags, db); | ||
|
|
||
| const revalidated = [...result.values()].some((v) => { | ||
| if (v == null) return false; | ||
| const { revalidatedAt, expire } = v; | ||
| if (expire != null) return expire <= now && expire > (lastModified ?? 0); | ||
| return revalidatedAt > (lastModified ?? now); |
| const expire = getExpire(v); | ||
| if (expire != null) return expire <= now && expire > (lastModified ?? 0); | ||
| return getRevalidatedAt(v) > (lastModified ?? now); |
| async hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean> { | ||
| const data = await this.getTagData(tags); | ||
| const lastModifiedOrNowMs = lastModified ?? Date.now(); | ||
| const revalidated = Object.values(data).some(({ revalidatedAt }) => revalidatedAt > lastModifiedOrNowMs); |
| * - **Stale**: at least one tag was revalidated after `lastModified` but has not yet expired → | ||
| * returns `true`. The cache entry is still served but `revalidate` is set to `1` to trigger | ||
| * background revalidation. |
Ported SWR PR's.
I haven't finished reviewing the port yet