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), + }, + }, + ], +};