diff --git a/.changeset/khaki-rice-applaud.md b/.changeset/khaki-rice-applaud.md
new file mode 100644
index 000000000..a319016c4
--- /dev/null
+++ b/.changeset/khaki-rice-applaud.md
@@ -0,0 +1,5 @@
+---
+"@opennextjs/aws": patch
+---
+
+fix fetch and unstable_cache not working for ISR requests
diff --git a/examples/app-router/app/isr-data-cache/page.tsx b/examples/app-router/app/isr-data-cache/page.tsx
new file mode 100644
index 000000000..2d02f14c3
--- /dev/null
+++ b/examples/app-router/app/isr-data-cache/page.tsx
@@ -0,0 +1,30 @@
+import { unstable_cache } from "next/cache";
+
+async function getTime() {
+ return new Date().toISOString();
+}
+
+const cachedTime = unstable_cache(getTime, { revalidate: false });
+
+export const revalidate = 10;
+
+export default async function ISR() {
+ const responseOpenNext = await fetch("https://opennext.js.org", {
+ cache: "force-cache",
+ });
+ const dateInOpenNext = responseOpenNext.headers.get("date");
+ const cachedTimeValue = await cachedTime();
+ const time = getTime();
+ return (
+
+
Date from from OpenNext
+
+ Date from from OpenNext: {dateInOpenNext}
+
+
Cached Time
+
Cached Time: {cachedTimeValue}
+
Time
+
Time: {time}
+
+ );
+}
diff --git a/examples/app-router/next.config.ts b/examples/app-router/next.config.ts
index 8a85d80a7..d1356ef9f 100644
--- a/examples/app-router/next.config.ts
+++ b/examples/app-router/next.config.ts
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
+ //TODO: remove this when i'll figure out why it fails locally
+ typescript: {
+ ignoreBuildErrors: true,
+ },
images: {
remotePatterns: [
{
diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts
index 770a8aa01..b062911b3 100644
--- a/packages/open-next/src/build/createServerBundle.ts
+++ b/packages/open-next/src/build/createServerBundle.ts
@@ -17,6 +17,10 @@ import { generateEdgeBundle } from "./edge/createEdgeBundle.js";
import * as buildHelper from "./helper.js";
import { installDependencies } from "./installDeps.js";
import { type CodePatcher, applyCodePatches } from "./patch/codePatcher.js";
+import {
+ patchFetchCacheForISR,
+ patchUnstableCacheForISR,
+} from "./patch/patchFetchCacheISR.js";
import { patchFetchCacheSetMissingWaitUntil } from "./patch/patchFetchCacheWaitUntil.js";
interface CodeCustomization {
@@ -181,6 +185,8 @@ async function generateBundle(
await applyCodePatches(options, tracedFiles, manifests, [
patchFetchCacheSetMissingWaitUntil,
+ patchFetchCacheForISR,
+ patchUnstableCacheForISR,
...additionalCodePatches,
]);
@@ -206,6 +212,12 @@ async function generateBundle(
"14.1",
);
+ const isAfter142 = buildHelper.compareSemver(
+ options.nextVersion,
+ ">=",
+ "14.2",
+ );
+
const disableRouting = isBefore13413 || config.middleware?.external;
const updater = new ContentUpdater(options);
@@ -221,6 +233,7 @@ async function generateBundle(
deletes: [
...(disableNextPrebundledReact ? ["applyNextjsPrebundledReact"] : []),
...(disableRouting ? ["withRouting"] : []),
+ ...(isAfter142 ? ["patchAsyncStorage"] : []),
],
}),
openNextReplacementPlugin({
diff --git a/packages/open-next/src/build/patch/patchFetchCacheISR.ts b/packages/open-next/src/build/patch/patchFetchCacheISR.ts
new file mode 100644
index 000000000..724969f93
--- /dev/null
+++ b/packages/open-next/src/build/patch/patchFetchCacheISR.ts
@@ -0,0 +1,113 @@
+import { Lang } from "@ast-grep/napi";
+import { getCrossPlatformPathRegex } from "utils/regex.js";
+import { createPatchCode } from "./astCodePatcher.js";
+import type { CodePatcher } from "./codePatcher";
+
+export const fetchRule = `
+rule:
+ kind: member_expression
+ pattern: $WORK_STORE.isOnDemandRevalidate
+ inside:
+ kind: ternary_expression
+ all:
+ - has: {kind: 'null'}
+ - has:
+ kind: await_expression
+ has:
+ kind: call_expression
+ all:
+ - has:
+ kind: member_expression
+ has:
+ kind: property_identifier
+ field: property
+ regex: get
+ - has:
+ kind: arguments
+ has:
+ kind: object
+ has:
+ kind: pair
+ all:
+ - has:
+ kind: property_identifier
+ field: key
+ regex: softTags
+ inside:
+ kind: variable_declarator
+
+fix:
+ ($WORK_STORE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)
+`;
+
+export const unstable_cacheRule = `
+rule:
+ kind: member_expression
+ pattern: $STORE_OR_CACHE.isOnDemandRevalidate
+ inside:
+ kind: if_statement
+ stopBy: end
+ has:
+ kind: statement_block
+ has:
+ kind: variable_declarator
+ has:
+ kind: await_expression
+ has:
+ kind: call_expression
+ all:
+ - has:
+ kind: member_expression
+ has:
+ kind: property_identifier
+ field: property
+ regex: get
+ - has:
+ kind: arguments
+ has:
+ kind: object
+ has:
+ kind: pair
+ all:
+ - has:
+ kind: property_identifier
+ field: key
+ regex: softTags
+ stopBy: end
+fix:
+ ($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)
+`;
+
+export const patchFetchCacheForISR: CodePatcher = {
+ name: "patch-fetch-cache-for-isr",
+ patches: [
+ {
+ versions: ">=14.0.0",
+ field: {
+ pathFilter: getCrossPlatformPathRegex(
+ String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|patch-fetch\.js)$`,
+ { escape: false },
+ ),
+ contentFilter: /\.isOnDemandRevalidate/,
+ patchCode: createPatchCode(fetchRule, Lang.JavaScript),
+ },
+ },
+ ],
+};
+
+export const patchUnstableCacheForISR: CodePatcher = {
+ name: "patch-unstable-cache-for-isr",
+ patches: [
+ {
+ versions: ">=14.2.0",
+ field: {
+ pathFilter: getCrossPlatformPathRegex(
+ String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|spec-extension/unstable-cache\.js)$`,
+ { escape: false },
+ ),
+ contentFilter: /\.isOnDemandRevalidate/,
+ patchCode: createPatchCode(unstable_cacheRule, Lang.JavaScript),
+ },
+ },
+ ],
+};
diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts
index 849c2ef71..38a17a776 100644
--- a/packages/open-next/src/core/requestHandler.ts
+++ b/packages/open-next/src/core/requestHandler.ts
@@ -31,7 +31,9 @@ import { requestHandler, setNextjsPrebundledReact } from "./util";
// This is used to identify requests in the cache
globalThis.__openNextAls = new AsyncLocalStorage();
+//#override patchAsyncStorage
patchAsyncStorage();
+//#endOverride
export async function openNextHandler(
internalEvent: InternalEvent,
diff --git a/packages/tests-e2e/tests/appRouter/isr.test.ts b/packages/tests-e2e/tests/appRouter/isr.test.ts
index 934925e98..4f6ae78ef 100644
--- a/packages/tests-e2e/tests/appRouter/isr.test.ts
+++ b/packages/tests-e2e/tests/appRouter/isr.test.ts
@@ -61,3 +61,35 @@ test("headers", async ({ page }) => {
await page.reload();
}
});
+
+test("Incremental Static Regeneration with data cache", async ({ page }) => {
+ test.setTimeout(45000);
+ await page.goto("/isr-data-cache");
+
+ const originalFetchedDate = await page
+ .getByTestId("fetched-date")
+ .textContent();
+ const originalCachedDate = await page
+ .getByTestId("cached-date")
+ .textContent();
+ const originalTime = await page.getByTestId("time").textContent();
+ await page.reload();
+
+ let finalTime = originalTime;
+ let finalCachedDate = originalCachedDate;
+ let finalFetchedDate = originalFetchedDate;
+
+ // Wait 10 + 1 seconds for ISR to regenerate time
+ await page.waitForTimeout(11000);
+ do {
+ await page.waitForTimeout(2000);
+ finalTime = await page.getByTestId("time").textContent();
+ finalCachedDate = await page.getByTestId("cached-date").textContent();
+ finalFetchedDate = await page.getByTestId("fetched-date").textContent();
+ await page.reload();
+ } while (originalTime === finalTime);
+
+ expect(originalTime).not.toEqual(finalTime);
+ expect(originalCachedDate).toEqual(finalCachedDate);
+ expect(originalFetchedDate).toEqual(finalFetchedDate);
+});
diff --git a/packages/tests-unit/tests/build/patch/patchFetchCacheISR.test.ts b/packages/tests-unit/tests/build/patch/patchFetchCacheISR.test.ts
new file mode 100644
index 000000000..61a09d501
--- /dev/null
+++ b/packages/tests-unit/tests/build/patch/patchFetchCacheISR.test.ts
@@ -0,0 +1,126 @@
+import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
+import {
+ fetchRule,
+ unstable_cacheRule,
+} from "@opennextjs/aws/build/patch/patchFetchCacheISR.js";
+import { describe } from "vitest";
+
+const unstable_cacheCode = `
+if (// when we are nested inside of other unstable_cache's
+ // we should bypass cache similar to fetches
+ !isNestedUnstableCache && workStore.fetchCache !== 'force-no-store' && !workStore.isOnDemandRevalidate && !incrementalCache.isOnDemandRevalidate && !workStore.isDraftMode) {
+ // We attempt to get the current cache entry from the incremental cache.
+ const cacheEntry = await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: options.revalidate,
+ tags,
+ softTags: implicitTags,
+ fetchIdx,
+ fetchUrl
+ });
+}
+else {
+ noStoreFetchIdx += 1;
+ // We are in Pages Router or were called outside of a render. We don't have a store
+ // so we just call the callback directly when it needs to run.
+ // If the entry is fresh we return it. If the entry is stale we return it but revalidate the entry in
+ // the background. If the entry is missing or invalid we generate a new entry and return it.
+ if (!incrementalCache.isOnDemandRevalidate) {
+ // We aren't doing an on demand revalidation so we check use the cache if valid
+ const implicitTags = !workUnitStore || workUnitStore.type === 'unstable-cache' ? [] : workUnitStore.implicitTags;
+ const cacheEntry = await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: options.revalidate,
+ tags,
+ fetchIdx,
+ fetchUrl,
+ softTags: implicitTags
+ });
+}
+`;
+
+const patchFetchCacheCodeUnMinified = `
+const entry = workStore.isOnDemandRevalidate ? null : await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: finalRevalidate,
+ fetchUrl,
+ fetchIdx,
+ tags,
+ softTags: implicitTags
+ });
+`;
+
+const patchFetchCacheCodeMinifiedNext15 = `
+let t=P.isOnDemandRevalidate?null:await V.get(n,{kind:l.IncrementalCacheKind.FETCH,revalidate:_,fetchUrl:y,fetchIdx:X,tags:N,softTags:C});
+`;
+
+describe("patchUnstableCacheForISR", () => {
+ test("on unminified code", async () => {
+ expect(
+ patchCode(unstable_cacheCode, unstable_cacheRule),
+ ).toMatchInlineSnapshot(`
+"if (// when we are nested inside of other unstable_cache's
+ // we should bypass cache similar to fetches
+ !isNestedUnstableCache && workStore.fetchCache !== 'force-no-store' && !(workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) && !(incrementalCache.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) && !workStore.isDraftMode) {
+ // We attempt to get the current cache entry from the incremental cache.
+ const cacheEntry = await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: options.revalidate,
+ tags,
+ softTags: implicitTags,
+ fetchIdx,
+ fetchUrl
+ });
+}
+else {
+ noStoreFetchIdx += 1;
+ // We are in Pages Router or were called outside of a render. We don't have a store
+ // so we just call the callback directly when it needs to run.
+ // If the entry is fresh we return it. If the entry is stale we return it but revalidate the entry in
+ // the background. If the entry is missing or invalid we generate a new entry and return it.
+ if (!(incrementalCache.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)) {
+ // We aren't doing an on demand revalidation so we check use the cache if valid
+ const implicitTags = !workUnitStore || workUnitStore.type === 'unstable-cache' ? [] : workUnitStore.implicitTags;
+ const cacheEntry = await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: options.revalidate,
+ tags,
+ fetchIdx,
+ fetchUrl,
+ softTags: implicitTags
+ });
+}
+"
+`);
+ });
+});
+
+describe("patchFetchCacheISR", () => {
+ describe("Next 15", () => {
+ test("on unminified code", async () => {
+ expect(
+ patchCode(patchFetchCacheCodeUnMinified, fetchRule),
+ ).toMatchInlineSnapshot(`
+"const entry = (workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) ? null : await incrementalCache.get(cacheKey, {
+ kind: _responsecache.IncrementalCacheKind.FETCH,
+ revalidate: finalRevalidate,
+ fetchUrl,
+ fetchIdx,
+ tags,
+ softTags: implicitTags
+ });
+"
+ `);
+ });
+
+ test("on minified code", async () => {
+ expect(
+ patchCode(patchFetchCacheCodeMinifiedNext15, fetchRule),
+ ).toMatchInlineSnapshot(`
+"let t=(P.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)?null:await V.get(n,{kind:l.IncrementalCacheKind.FETCH,revalidate:_,fetchUrl:y,fetchIdx:X,tags:N,softTags:C});
+"
+`);
+ });
+ });
+ //TODO: Add test for Next 14.2.24
+});