diff --git a/.changeset/major-mails-shave.md b/.changeset/major-mails-shave.md
new file mode 100644
index 00000000..63cc1c64
--- /dev/null
+++ b/.changeset/major-mails-shave.md
@@ -0,0 +1,5 @@
+---
+"@opennextjs/cloudflare": patch
+---
+
+fix fetch inside `use cache` in ISR
diff --git a/examples/e2e/experimental/e2e/use-cache.test.ts b/examples/e2e/experimental/e2e/use-cache.test.ts
index 0728419a..80c1257b 100644
--- a/examples/e2e/experimental/e2e/use-cache.test.ts
+++ b/examples/e2e/experimental/e2e/use-cache.test.ts
@@ -83,4 +83,45 @@ test.describe("Composable Cache", () => {
const fullyCachedText = await fullyCachedElt.textContent();
expect(fullyCachedText).toEqual(initialFullyCachedText);
});
+
+ test("cached fetch should work in ISR", async ({ page }) => {
+ await page.goto("/use-cache/fetch");
+
+ let dateElt = page.getByTestId("date");
+ await expect(dateElt).toBeVisible();
+
+ let initialDate = await dateElt.textContent();
+
+ let isrElt = page.getByTestId("isr");
+ await expect(isrElt).toBeVisible();
+ let initialIsrText = await isrElt.textContent();
+
+ // We have to force reload until ISR has triggered at least once, otherwise the test will be flakey
+
+ let isrText = initialIsrText;
+
+ while (isrText === initialIsrText) {
+ await page.reload();
+ isrElt = page.getByTestId("isr");
+ dateElt = page.getByTestId("date");
+ await expect(isrElt).toBeVisible();
+ isrText = await isrElt.textContent();
+ await expect(dateElt).toBeVisible();
+ initialDate = await dateElt.textContent();
+ await page.waitForTimeout(1000);
+ }
+ initialIsrText = isrText;
+
+ do {
+ await page.reload();
+ dateElt = page.getByTestId("date");
+ isrElt = page.getByTestId("isr");
+ await expect(dateElt).toBeVisible();
+ await expect(isrElt).toBeVisible();
+ isrText = await isrElt.textContent();
+ await page.waitForTimeout(1000);
+ } while (isrText === initialIsrText);
+ const fullyCachedText = await dateElt.textContent();
+ expect(fullyCachedText).toEqual(initialDate);
+ });
});
diff --git a/examples/e2e/experimental/src/app/use-cache/fetch/page.tsx b/examples/e2e/experimental/src/app/use-cache/fetch/page.tsx
new file mode 100644
index 00000000..f5e7c516
--- /dev/null
+++ b/examples/e2e/experimental/src/app/use-cache/fetch/page.tsx
@@ -0,0 +1,22 @@
+import { ISRComponent } from "@/components/cached";
+import { Suspense } from "react";
+
+async function getFromFetch() {
+ "use cache";
+ // This is a simple fetch to ensure that the cache is working with IO inside
+ const res = await fetch("https://opennext.js.org");
+ return res.headers.get("Date");
+}
+
+export default async function Page() {
+ const date = await getFromFetch();
+ return (
+
+
Cache
+
{date}
+
Loading...}>
+
+
+
+ );
+}
diff --git a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts
index e2b8074f..bfe922df 100644
--- a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts
+++ b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts
@@ -36,6 +36,7 @@ import type { Plugin } from "esbuild";
import { getOpenNextConfig } from "../../../api/config.js";
import { patchResRevalidate } from "../patches/plugins/res-revalidate.js";
+import { patchUseCacheIO } from "../patches/plugins/use-cache.js";
import { normalizePath } from "../utils/index.js";
import { copyWorkerdPackages } from "../utils/workerd.js";
@@ -216,6 +217,7 @@ async function generateBundle(
patchBackgroundRevalidation,
// Cloudflare specific patches
patchResRevalidate,
+ patchUseCacheIO,
...additionalCodePatches,
]);
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/use-cache.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/use-cache.spec.ts
new file mode 100644
index 00000000..5134d7b2
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/use-cache.spec.ts
@@ -0,0 +1,159 @@
+import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
+import { expect, test } from "vitest";
+
+import { rule } from "./use-cache.js";
+
+const codeToPatch = `"use strict";
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+0 && (module.exports = {
+ bindSnapshot: null,
+ createAsyncLocalStorage: null,
+ createSnapshot: null
+});
+function _export(target, all) {
+ for(var name in all)Object.defineProperty(target, name, {
+ enumerable: true,
+ get: all[name]
+ });
+}
+_export(exports, {
+ bindSnapshot: function() {
+ return bindSnapshot;
+ },
+ createAsyncLocalStorage: function() {
+ return createAsyncLocalStorage;
+ },
+ createSnapshot: function() {
+ return createSnapshot;
+ }
+});
+const sharedAsyncLocalStorageNotAvailableError = Object.defineProperty(new Error('Invariant: AsyncLocalStorage accessed in runtime where it is not available'), "__NEXT_ERROR_CODE", {
+ value: "E504",
+ enumerable: false,
+ configurable: true
+});
+class FakeAsyncLocalStorage {
+ disable() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ getStore() {
+ // This fake implementation of AsyncLocalStorage always returns \`undefined\`.
+ return undefined;
+ }
+ run() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ exit() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ enterWith() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ static bind(fn) {
+ return fn;
+ }
+}
+const maybeGlobalAsyncLocalStorage = typeof globalThis !== 'undefined' && globalThis.AsyncLocalStorage;
+function createAsyncLocalStorage() {
+ if (maybeGlobalAsyncLocalStorage) {
+ return new maybeGlobalAsyncLocalStorage();
+ }
+ return new FakeAsyncLocalStorage();
+}
+function bindSnapshot(fn) {
+ if (maybeGlobalAsyncLocalStorage) {
+ return maybeGlobalAsyncLocalStorage.bind(fn);
+ }
+ return FakeAsyncLocalStorage.bind(fn);
+}
+function createSnapshot() {
+ if (maybeGlobalAsyncLocalStorage) {
+ return maybeGlobalAsyncLocalStorage.snapshot();
+ }
+ return function(fn, ...args) {
+ return fn(...args);
+ };
+}
+
+//# sourceMappingURL=async-local-storage.js.map
+`;
+
+test("patch the createSnapshot function", () => {
+ const patchedCode = patchCode(codeToPatch, rule);
+ expect(patchedCode).toMatchInlineSnapshot(`""use strict";
+Object.defineProperty(exports, "__esModule", {
+ value: true
+});
+0 && (module.exports = {
+ bindSnapshot: null,
+ createAsyncLocalStorage: null,
+ createSnapshot: null
+});
+function _export(target, all) {
+ for(var name in all)Object.defineProperty(target, name, {
+ enumerable: true,
+ get: all[name]
+ });
+}
+_export(exports, {
+ bindSnapshot: function() {
+ return bindSnapshot;
+ },
+ createAsyncLocalStorage: function() {
+ return createAsyncLocalStorage;
+ },
+ createSnapshot: function() {
+ return createSnapshot;
+ }
+});
+const sharedAsyncLocalStorageNotAvailableError = Object.defineProperty(new Error('Invariant: AsyncLocalStorage accessed in runtime where it is not available'), "__NEXT_ERROR_CODE", {
+ value: "E504",
+ enumerable: false,
+ configurable: true
+});
+class FakeAsyncLocalStorage {
+ disable() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ getStore() {
+ // This fake implementation of AsyncLocalStorage always returns \`undefined\`.
+ return undefined;
+ }
+ run() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ exit() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ enterWith() {
+ throw sharedAsyncLocalStorageNotAvailableError;
+ }
+ static bind(fn) {
+ return fn;
+ }
+}
+const maybeGlobalAsyncLocalStorage = typeof globalThis !== 'undefined' && globalThis.AsyncLocalStorage;
+function createAsyncLocalStorage() {
+ if (maybeGlobalAsyncLocalStorage) {
+ return new maybeGlobalAsyncLocalStorage();
+ }
+ return new FakeAsyncLocalStorage();
+}
+function bindSnapshot(fn) {
+ if (maybeGlobalAsyncLocalStorage) {
+ return maybeGlobalAsyncLocalStorage.bind(fn);
+ }
+ return FakeAsyncLocalStorage.bind(fn);
+}
+function createSnapshot() {
+ // Ignored snapshot
+ return function(fn, ...args) {
+ return fn(...args);
+ };
+}
+
+//# sourceMappingURL=async-local-storage.js.map
+"`);
+});
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/use-cache.ts b/packages/cloudflare/src/cli/build/patches/plugins/use-cache.ts
new file mode 100644
index 00000000..b7ffa0a6
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/use-cache.ts
@@ -0,0 +1,43 @@
+/**
+ * This patch will replace the createSnapshot function in the
+ * server/app-render/async-local-storage.js file to an empty string.
+ * This is necessary because the createSnapshot function is causing I/O issues for
+ * ISR/SSG revalidation in Cloudflare Workers.
+ * This is because by default it will use AsyncLocalStorage.snapshot() and it will
+ * bind everything to the initial request context.
+ * The downsides is that use cache function will have access to the full request
+ * ALS context from next (i.e. cookies, headers ...)
+ * TODO: Find a better fix for this issue.
+ */
+import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
+import type { CodePatcher } from "@opennextjs/aws/build/patch/codePatcher.js";
+import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
+
+export const rule = `
+rule:
+ kind: if_statement
+ inside:
+ kind: function_declaration
+ stopBy: end
+ has:
+ kind: identifier
+ pattern: createSnapshot
+fix:
+ '// Ignored snapshot'
+`;
+
+export const patchUseCacheIO: CodePatcher = {
+ name: "patch-use-cache",
+ patches: [
+ {
+ versions: ">=15.3.1",
+ field: {
+ pathFilter: getCrossPlatformPathRegex(String.raw`server/app-render/async-local-storage\.js$`, {
+ escape: false,
+ }),
+ contentFilter: /createSnapshot/,
+ patchCode: async ({ code }) => patchCode(code, rule),
+ },
+ },
+ ],
+};