diff --git a/.changeset/weak-socks-remain.md b/.changeset/weak-socks-remain.md new file mode 100644 index 000000000..10bf2a85d --- /dev/null +++ b/.changeset/weak-socks-remain.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Fix regional cache issue with SWR kind of revalidateTag diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index d01583932..6f08c7acc 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -54,7 +54,7 @@ "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "catalog:", - "@opennextjs/aws": "4.0.2", + "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@1193", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts index 2513d0e0d..ddfb14313 100644 --- a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts +++ b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts @@ -69,6 +69,22 @@ interface PutToCacheInput { entry: IncrementalCacheEntry; } +type PendingCacheEntry = + // Cache miss: the value was fetched from the store and is ready to be stored in the regional cache. + | { type: "put"; input: PutToCacheInput } + // Cache hit with shouldLazilyUpdateOnCacheHit: needs a fresh fetch from the store before storing. + | { type: "lazy-update"; cacheType?: CacheEntryType }; + +/** + * Returns the per-request pending regional cache entries map from the OpenNext ALS store. + * Returns `undefined` when the ALS store is not available (e.g. SSG or non-request contexts). + */ +function getPendingEntries(): Map | undefined { + return globalThis.__openNextAls + ?.getStore() + ?.requestCache.getOrCreate("regional-cache:pending"); +} + /** * Wrapper adding a regional cache on an `IncrementalCache` implementation. * @@ -134,18 +150,11 @@ class RegionalCache implements IncrementalCache { if (cachedResponse) { debugCache("RegionalCache", `get ${key} -> cached response`); - // Re-fetch from the store and update the regional cache in the background. - // Note: this is only useful when the Cache API is not purged automatically. + // Defer the lazy update so getTagCacheResult() can decide whether it should happen. + // If the entry turns out to be stale or revalidated we must not refresh the regional + // cache with outdated data. if (this.opts.shouldLazilyUpdateOnCacheHit) { - getCloudflareContext().ctx.waitUntil( - this.store.get(key, cacheType).then(async (rawEntry) => { - const { value, lastModified } = rawEntry ?? {}; - - if (value && typeof lastModified === "number") { - await this.putToCache({ key, cacheType, entry: { value, lastModified } }); - } - }) - ); + getPendingEntries()?.set(key, { type: "lazy-update", cacheType }); } const responseJson: Record = await cachedResponse.json(); @@ -162,10 +171,12 @@ class RegionalCache implements IncrementalCache { debugCache("RegionalCache", `get ${key} -> put to cache`); - // Update the locale cache after retrieving from the store. - getCloudflareContext().ctx.waitUntil( - this.putToCache({ key, cacheType, entry: { value, lastModified } }) - ); + // Defer storing into the regional cache until getTagCacheResult() confirms the entry + // is neither stale nor revalidated. Storing immediately could propagate stale data. + getPendingEntries()?.set(key, { + type: "put", + input: { key, cacheType, entry: { value, lastModified } }, + }); return { value, lastModified }; } catch (e) { @@ -211,6 +222,54 @@ class RegionalCache implements IncrementalCache { } } + async getTagCacheResult(params: { + key: string; + hasBeenRevalidated: boolean; + isStaleFromTag: boolean; + isStaleFromTime?: boolean; + }): Promise { + const { key, hasBeenRevalidated, isStaleFromTag, isStaleFromTime } = params; + + const pending = getPendingEntries(); + const entry = pending?.get(key); + if (!entry) return; + + // Consume the entry: only one storage decision per get() call. + pending!.delete(key); + + if (hasBeenRevalidated) { + // The entry was revalidated on-demand; set() will write the fresh content. + // Storing the old value would serve stale data until the regional cache expires. + return; + } + if (isStaleFromTag) { + // The entry is within an SWR stale window triggered by a tag revalidation. + // Storing it would extend the stale window in the regional cache and break SWR semantics. + return; + } + if (isStaleFromTime) { + // The entry has exceeded its time-based revalidation interval. + // Avoid caching it regionally to prevent serving outdated content longer than intended. + return; + } + + const { ctx } = getCloudflareContext(); + + if (entry.type === "put") { + ctx.waitUntil(this.putToCache(entry.input)); + } else { + // lazy-update: re-fetch the entry from the store, then refresh the regional cache. + ctx.waitUntil( + this.store.get(key, entry.cacheType).then(async (rawEntry) => { + const { value, lastModified } = rawEntry ?? {}; + if (value && typeof lastModified === "number") { + await this.putToCache({ key, cacheType: entry.cacheType, entry: { value, lastModified } }); + } + }) + ); + } + } + protected async getCacheInstance(): Promise { if (this.localCache) return this.localCache; @@ -242,8 +301,8 @@ class RegionalCache implements IncrementalCache { "cache-control": `max-age=${age}`, ...(tags.length > 0 ? { - "cache-tag": tags.join(","), - } + "cache-tag": tags.join(","), + } : {}), }), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78f29f69d..b557af631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1081,8 +1081,8 @@ importers: specifier: 'catalog:' version: 1.31.0 '@opennextjs/aws': - specifier: 4.0.2 - version: 4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + specifier: https://pkg.pr.new/@opennextjs/aws@1193 + version: https://pkg.pr.new/@opennextjs/aws@1193(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) ci-info: specifier: ^4.2.0 version: 4.2.0 @@ -3448,8 +3448,9 @@ packages: '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - '@opennextjs/aws@4.0.2': - resolution: {integrity: sha512-nXQPT8GZDV+NWMJV9mD/Ywxyo+tCZfFvrR6jpEOYNcxK/AqiEDlJzPybGOrHUDWAYdR1b6tmh+MoR3VbqIao3w==} + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@1193': + resolution: {integrity: sha512-IuvfZHX3zcRMzIsvk7bhrP4Vi4rQRZztbs9TvGJoPe9QskEEPjjRX6nEcbEwL6Q6aBKTBvVa4WTWuRvID9ajxA==, tarball: https://pkg.pr.new/@opennextjs/aws@1193} + version: 4.0.3 hasBin: true peerDependencies: next: '>=15.5.18 <16 || >=16.2.6' @@ -12556,7 +12557,7 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 - '@opennextjs/aws@4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@1193(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0 @@ -15825,8 +15826,8 @@ snapshots: '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.1.0(eslint@8.57.1) @@ -15845,19 +15846,19 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -15921,14 +15922,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.11.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.11.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -15965,14 +15966,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -16019,7 +16020,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -16030,7 +16031,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3