Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/weak-socks-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

Fix regional cache issue with SWR kind of revalidateTag
2 changes: 1 addition & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ interface PutToCacheInput {
entry: IncrementalCacheEntry<CacheEntryType>;
}

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<string, PendingCacheEntry> | undefined {
return globalThis.__openNextAls
?.getStore()
?.requestCache.getOrCreate<string, PendingCacheEntry>("regional-cache:pending");
}

/**
* Wrapper adding a regional cache on an `IncrementalCache` implementation.
*
Expand Down Expand Up @@ -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<string, unknown> = await cachedResponse.json();
Expand All @@ -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) {
Expand Down Expand Up @@ -211,6 +222,54 @@ class RegionalCache implements IncrementalCache {
}
}

async getTagCacheResult(params: {
key: string;
hasBeenRevalidated: boolean;
isStaleFromTag: boolean;
isStaleFromTime?: boolean;
}): Promise<void> {
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<Cache> {
if (this.localCache) return this.localCache;

Expand Down Expand Up @@ -242,8 +301,8 @@ class RegionalCache implements IncrementalCache {
"cache-control": `max-age=${age}`,
...(tags.length > 0
? {
"cache-tag": tags.join(","),
}
"cache-tag": tags.join(","),
}
: {}),
}),
})
Expand Down
33 changes: 17 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading