Skip to content

Commit 652550f

Browse files
authored
Fix fetch cache for isr (#783)
* patch for the fetch cache with ISR * update rule for unstable_cache * add e2e test * fix linting * changeset & review * remove patchAsyncStorage on next 14.2+ * fix linting
1 parent 73bffc0 commit 652550f

File tree

8 files changed

+325
-0
lines changed

8 files changed

+325
-0
lines changed

.changeset/khaki-rice-applaud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
fix fetch and unstable_cache not working for ISR requests
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { unstable_cache } from "next/cache";
2+
3+
async function getTime() {
4+
return new Date().toISOString();
5+
}
6+
7+
const cachedTime = unstable_cache(getTime, { revalidate: false });
8+
9+
export const revalidate = 10;
10+
11+
export default async function ISR() {
12+
const responseOpenNext = await fetch("https://opennext.js.org", {
13+
cache: "force-cache",
14+
});
15+
const dateInOpenNext = responseOpenNext.headers.get("date");
16+
const cachedTimeValue = await cachedTime();
17+
const time = getTime();
18+
return (
19+
<div>
20+
<h1>Date from from OpenNext</h1>
21+
<p data-testid="fetched-date">
22+
Date from from OpenNext: {dateInOpenNext}
23+
</p>
24+
<h1>Cached Time</h1>
25+
<p data-testid="cached-date">Cached Time: {cachedTimeValue}</p>
26+
<h1>Time</h1>
27+
<p data-testid="time">Time: {time}</p>
28+
</div>
29+
);
30+
}

examples/app-router/next.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
99
eslint: {
1010
ignoreDuringBuilds: true,
1111
},
12+
//TODO: remove this when i'll figure out why it fails locally
13+
typescript: {
14+
ignoreBuildErrors: true,
15+
},
1216
images: {
1317
remotePatterns: [
1418
{

packages/open-next/src/build/createServerBundle.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { generateEdgeBundle } from "./edge/createEdgeBundle.js";
1717
import * as buildHelper from "./helper.js";
1818
import { installDependencies } from "./installDeps.js";
1919
import { type CodePatcher, applyCodePatches } from "./patch/codePatcher.js";
20+
import {
21+
patchFetchCacheForISR,
22+
patchUnstableCacheForISR,
23+
} from "./patch/patchFetchCacheISR.js";
2024
import { patchFetchCacheSetMissingWaitUntil } from "./patch/patchFetchCacheWaitUntil.js";
2125

2226
interface CodeCustomization {
@@ -181,6 +185,8 @@ async function generateBundle(
181185

182186
await applyCodePatches(options, tracedFiles, manifests, [
183187
patchFetchCacheSetMissingWaitUntil,
188+
patchFetchCacheForISR,
189+
patchUnstableCacheForISR,
184190
...additionalCodePatches,
185191
]);
186192

@@ -206,6 +212,12 @@ async function generateBundle(
206212
"14.1",
207213
);
208214

215+
const isAfter142 = buildHelper.compareSemver(
216+
options.nextVersion,
217+
">=",
218+
"14.2",
219+
);
220+
209221
const disableRouting = isBefore13413 || config.middleware?.external;
210222

211223
const updater = new ContentUpdater(options);
@@ -221,6 +233,7 @@ async function generateBundle(
221233
deletes: [
222234
...(disableNextPrebundledReact ? ["applyNextjsPrebundledReact"] : []),
223235
...(disableRouting ? ["withRouting"] : []),
236+
...(isAfter142 ? ["patchAsyncStorage"] : []),
224237
],
225238
}),
226239
openNextReplacementPlugin({
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Lang } from "@ast-grep/napi";
2+
import { getCrossPlatformPathRegex } from "utils/regex.js";
3+
import { createPatchCode } from "./astCodePatcher.js";
4+
import type { CodePatcher } from "./codePatcher";
5+
6+
export const fetchRule = `
7+
rule:
8+
kind: member_expression
9+
pattern: $WORK_STORE.isOnDemandRevalidate
10+
inside:
11+
kind: ternary_expression
12+
all:
13+
- has: {kind: 'null'}
14+
- has:
15+
kind: await_expression
16+
has:
17+
kind: call_expression
18+
all:
19+
- has:
20+
kind: member_expression
21+
has:
22+
kind: property_identifier
23+
field: property
24+
regex: get
25+
- has:
26+
kind: arguments
27+
has:
28+
kind: object
29+
has:
30+
kind: pair
31+
all:
32+
- has:
33+
kind: property_identifier
34+
field: key
35+
regex: softTags
36+
inside:
37+
kind: variable_declarator
38+
39+
fix:
40+
($WORK_STORE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)
41+
`;
42+
43+
export const unstable_cacheRule = `
44+
rule:
45+
kind: member_expression
46+
pattern: $STORE_OR_CACHE.isOnDemandRevalidate
47+
inside:
48+
kind: if_statement
49+
stopBy: end
50+
has:
51+
kind: statement_block
52+
has:
53+
kind: variable_declarator
54+
has:
55+
kind: await_expression
56+
has:
57+
kind: call_expression
58+
all:
59+
- has:
60+
kind: member_expression
61+
has:
62+
kind: property_identifier
63+
field: property
64+
regex: get
65+
- has:
66+
kind: arguments
67+
has:
68+
kind: object
69+
has:
70+
kind: pair
71+
all:
72+
- has:
73+
kind: property_identifier
74+
field: key
75+
regex: softTags
76+
stopBy: end
77+
fix:
78+
($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)
79+
`;
80+
81+
export const patchFetchCacheForISR: CodePatcher = {
82+
name: "patch-fetch-cache-for-isr",
83+
patches: [
84+
{
85+
versions: ">=14.0.0",
86+
field: {
87+
pathFilter: getCrossPlatformPathRegex(
88+
String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|patch-fetch\.js)$`,
89+
{ escape: false },
90+
),
91+
contentFilter: /\.isOnDemandRevalidate/,
92+
patchCode: createPatchCode(fetchRule, Lang.JavaScript),
93+
},
94+
},
95+
],
96+
};
97+
98+
export const patchUnstableCacheForISR: CodePatcher = {
99+
name: "patch-unstable-cache-for-isr",
100+
patches: [
101+
{
102+
versions: ">=14.2.0",
103+
field: {
104+
pathFilter: getCrossPlatformPathRegex(
105+
String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|spec-extension/unstable-cache\.js)$`,
106+
{ escape: false },
107+
),
108+
contentFilter: /\.isOnDemandRevalidate/,
109+
patchCode: createPatchCode(unstable_cacheRule, Lang.JavaScript),
110+
},
111+
},
112+
],
113+
};

packages/open-next/src/core/requestHandler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import { requestHandler, setNextjsPrebundledReact } from "./util";
3131
// This is used to identify requests in the cache
3232
globalThis.__openNextAls = new AsyncLocalStorage();
3333

34+
//#override patchAsyncStorage
3435
patchAsyncStorage();
36+
//#endOverride
3537

3638
export async function openNextHandler(
3739
internalEvent: InternalEvent,

packages/tests-e2e/tests/appRouter/isr.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,35 @@ test("headers", async ({ page }) => {
6161
await page.reload();
6262
}
6363
});
64+
65+
test("Incremental Static Regeneration with data cache", async ({ page }) => {
66+
test.setTimeout(45000);
67+
await page.goto("/isr-data-cache");
68+
69+
const originalFetchedDate = await page
70+
.getByTestId("fetched-date")
71+
.textContent();
72+
const originalCachedDate = await page
73+
.getByTestId("cached-date")
74+
.textContent();
75+
const originalTime = await page.getByTestId("time").textContent();
76+
await page.reload();
77+
78+
let finalTime = originalTime;
79+
let finalCachedDate = originalCachedDate;
80+
let finalFetchedDate = originalFetchedDate;
81+
82+
// Wait 10 + 1 seconds for ISR to regenerate time
83+
await page.waitForTimeout(11000);
84+
do {
85+
await page.waitForTimeout(2000);
86+
finalTime = await page.getByTestId("time").textContent();
87+
finalCachedDate = await page.getByTestId("cached-date").textContent();
88+
finalFetchedDate = await page.getByTestId("fetched-date").textContent();
89+
await page.reload();
90+
} while (originalTime === finalTime);
91+
92+
expect(originalTime).not.toEqual(finalTime);
93+
expect(originalCachedDate).toEqual(finalCachedDate);
94+
expect(originalFetchedDate).toEqual(finalFetchedDate);
95+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
2+
import {
3+
fetchRule,
4+
unstable_cacheRule,
5+
} from "@opennextjs/aws/build/patch/patchFetchCacheISR.js";
6+
import { describe } from "vitest";
7+
8+
const unstable_cacheCode = `
9+
if (// when we are nested inside of other unstable_cache's
10+
// we should bypass cache similar to fetches
11+
!isNestedUnstableCache && workStore.fetchCache !== 'force-no-store' && !workStore.isOnDemandRevalidate && !incrementalCache.isOnDemandRevalidate && !workStore.isDraftMode) {
12+
// We attempt to get the current cache entry from the incremental cache.
13+
const cacheEntry = await incrementalCache.get(cacheKey, {
14+
kind: _responsecache.IncrementalCacheKind.FETCH,
15+
revalidate: options.revalidate,
16+
tags,
17+
softTags: implicitTags,
18+
fetchIdx,
19+
fetchUrl
20+
});
21+
}
22+
else {
23+
noStoreFetchIdx += 1;
24+
// We are in Pages Router or were called outside of a render. We don't have a store
25+
// so we just call the callback directly when it needs to run.
26+
// If the entry is fresh we return it. If the entry is stale we return it but revalidate the entry in
27+
// the background. If the entry is missing or invalid we generate a new entry and return it.
28+
if (!incrementalCache.isOnDemandRevalidate) {
29+
// We aren't doing an on demand revalidation so we check use the cache if valid
30+
const implicitTags = !workUnitStore || workUnitStore.type === 'unstable-cache' ? [] : workUnitStore.implicitTags;
31+
const cacheEntry = await incrementalCache.get(cacheKey, {
32+
kind: _responsecache.IncrementalCacheKind.FETCH,
33+
revalidate: options.revalidate,
34+
tags,
35+
fetchIdx,
36+
fetchUrl,
37+
softTags: implicitTags
38+
});
39+
}
40+
`;
41+
42+
const patchFetchCacheCodeUnMinified = `
43+
const entry = workStore.isOnDemandRevalidate ? null : await incrementalCache.get(cacheKey, {
44+
kind: _responsecache.IncrementalCacheKind.FETCH,
45+
revalidate: finalRevalidate,
46+
fetchUrl,
47+
fetchIdx,
48+
tags,
49+
softTags: implicitTags
50+
});
51+
`;
52+
53+
const patchFetchCacheCodeMinifiedNext15 = `
54+
let t=P.isOnDemandRevalidate?null:await V.get(n,{kind:l.IncrementalCacheKind.FETCH,revalidate:_,fetchUrl:y,fetchIdx:X,tags:N,softTags:C});
55+
`;
56+
57+
describe("patchUnstableCacheForISR", () => {
58+
test("on unminified code", async () => {
59+
expect(
60+
patchCode(unstable_cacheCode, unstable_cacheRule),
61+
).toMatchInlineSnapshot(`
62+
"if (// when we are nested inside of other unstable_cache's
63+
// we should bypass cache similar to fetches
64+
!isNestedUnstableCache && workStore.fetchCache !== 'force-no-store' && !(workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) && !(incrementalCache.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) && !workStore.isDraftMode) {
65+
// We attempt to get the current cache entry from the incremental cache.
66+
const cacheEntry = await incrementalCache.get(cacheKey, {
67+
kind: _responsecache.IncrementalCacheKind.FETCH,
68+
revalidate: options.revalidate,
69+
tags,
70+
softTags: implicitTags,
71+
fetchIdx,
72+
fetchUrl
73+
});
74+
}
75+
else {
76+
noStoreFetchIdx += 1;
77+
// We are in Pages Router or were called outside of a render. We don't have a store
78+
// so we just call the callback directly when it needs to run.
79+
// If the entry is fresh we return it. If the entry is stale we return it but revalidate the entry in
80+
// the background. If the entry is missing or invalid we generate a new entry and return it.
81+
if (!(incrementalCache.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)) {
82+
// We aren't doing an on demand revalidation so we check use the cache if valid
83+
const implicitTags = !workUnitStore || workUnitStore.type === 'unstable-cache' ? [] : workUnitStore.implicitTags;
84+
const cacheEntry = await incrementalCache.get(cacheKey, {
85+
kind: _responsecache.IncrementalCacheKind.FETCH,
86+
revalidate: options.revalidate,
87+
tags,
88+
fetchIdx,
89+
fetchUrl,
90+
softTags: implicitTags
91+
});
92+
}
93+
"
94+
`);
95+
});
96+
});
97+
98+
describe("patchFetchCacheISR", () => {
99+
describe("Next 15", () => {
100+
test("on unminified code", async () => {
101+
expect(
102+
patchCode(patchFetchCacheCodeUnMinified, fetchRule),
103+
).toMatchInlineSnapshot(`
104+
"const entry = (workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) ? null : await incrementalCache.get(cacheKey, {
105+
kind: _responsecache.IncrementalCacheKind.FETCH,
106+
revalidate: finalRevalidate,
107+
fetchUrl,
108+
fetchIdx,
109+
tags,
110+
softTags: implicitTags
111+
});
112+
"
113+
`);
114+
});
115+
116+
test("on minified code", async () => {
117+
expect(
118+
patchCode(patchFetchCacheCodeMinifiedNext15, fetchRule),
119+
).toMatchInlineSnapshot(`
120+
"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});
121+
"
122+
`);
123+
});
124+
});
125+
//TODO: Add test for Next 14.2.24
126+
});

0 commit comments

Comments
 (0)