Skip to content

Commit de72c13

Browse files
committed
fix: stop endless RSC prefetch requests in Next.js 16.3
1 parent 2f863ad commit de72c13

10 files changed

Lines changed: 623 additions & 27 deletions

File tree

.changeset/curvy-lions-prefetch.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
fix: serve cached segment prefetches when Next.js prefetch inlining is enabled
6+
7+
Prevent Next.js 16.3 clients from repeatedly requesting the route tree when cache interception is enabled.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Link from "next/link";
2+
3+
export default function PrefetchPage() {
4+
return <Link href="/prefetch/target">Target</Link>;
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function PrefetchTargetPage() {
2+
return <p>Target page</p>;
3+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("cache interception serves segment prefetches without retrying indefinitely", async ({ page }) => {
4+
const segmentPrefetches: string[] = [];
5+
page.on("request", (request) => {
6+
if (new URL(request.url()).pathname !== "/prefetch/target") {
7+
return;
8+
}
9+
10+
const segment = request.headers()["next-router-segment-prefetch"];
11+
if (segment) {
12+
segmentPrefetches.push(segment);
13+
}
14+
});
15+
16+
const routeTreeResponsePromise = page.waitForResponse((response) => {
17+
const request = response.request();
18+
return (
19+
new URL(request.url()).pathname === "/prefetch/target" &&
20+
request.headers()["next-router-segment-prefetch"] === "/_tree"
21+
);
22+
});
23+
24+
await page.goto("/prefetch");
25+
26+
const routeTreeResponse = await routeTreeResponsePromise;
27+
expect(routeTreeResponse.status()).toBe(200);
28+
expect(routeTreeResponse.headers()).toMatchObject({
29+
"content-type": "text/x-component",
30+
"x-nextjs-postponed": "2",
31+
"x-nextjs-prerender": "1",
32+
"x-opennext-cache": "HIT",
33+
});
34+
35+
await expect.poll(() => segmentPrefetches.some((segment) => segment.endsWith("/__PAGE__"))).toBe(true);
36+
await page.waitForLoadState("networkidle");
37+
38+
const settledPrefetchCount = segmentPrefetches.length;
39+
await page.waitForTimeout(1_000);
40+
41+
expect(segmentPrefetches).toHaveLength(settledPrefetchCount);
42+
expect(settledPrefetchCount).toBeLessThan(10);
43+
});

examples/playground16/open-next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-t
55

66
export default {
77
...defineCloudflareConfig({
8+
enableCacheInterception: true,
89
incrementalCache: r2IncrementalCache,
910
queue: doQueue,
1011
tagCache: d1NextTagCache,

examples/playground16/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"cf-typegen": "wrangler types --env-interface CloudflareEnv"
1818
},
1919
"dependencies": {
20-
"next": "16.2.11",
20+
"next": "16.3.1",
2121
"react-dom": "^19.2.6",
2222
"react": "^19.2.6",
2323
"shiki": "^3.22.0"

packages/cloudflare/src/cli/build/build.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { compileInit } from "./open-next/compile-init.js";
1919
import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
2020
import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
2121
import { createServerBundle } from "./open-next/createServerBundle.js";
22+
import { patchCacheInterceptor } from "./patches/ast/cache-interceptor.js";
2223
import { useNodeMiddleware } from "./utils/middleware.js";
2324
import { getVersion } from "./utils/version.js";
2425

@@ -100,6 +101,9 @@ export async function build(
100101

101102
// Compile middleware
102103
await createMiddleware(options, { forceOnlyBuildOnce: true });
104+
if (config.dangerous?.enableCacheInterception === true) {
105+
patchCacheInterceptor(options);
106+
}
103107

104108
createStaticAssets(options, { useBasePath: true });
105109

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { readFileSync } from "node:fs";
2+
3+
import mockFs from "mock-fs";
4+
import { afterEach, describe, expect, test } from "vitest";
5+
6+
import { patchCacheInterceptor, patchCacheInterceptorSource } from "./cache-interceptor.js";
7+
8+
const cacheInterceptorSource = `
9+
function getBodyForAppRouter(event, cachedValue) {
10+
const segmentHeader = \`\${event.headers[NEXT_SEGMENT_PREFETCH_HEADER]}\`;
11+
const isSegmentResponse =
12+
Boolean(segmentHeader) &&
13+
segmentHeader in (cachedValue.segmentData || {}) &&
14+
!NextConfig.experimental?.prefetchInlining;
15+
const body = isSegmentResponse
16+
? cachedValue.segmentData[segmentHeader]
17+
: cachedValue.rsc;
18+
return { body };
19+
}
20+
`;
21+
22+
describe("patchCacheInterceptor", () => {
23+
afterEach(() => mockFs.restore());
24+
25+
test("patches the generated middleware", () => {
26+
const outputDir = "/app/.open-next";
27+
const middlewarePath = `${outputDir}/middleware/handler.mjs`;
28+
mockFs({ [middlewarePath]: cacheInterceptorSource });
29+
30+
patchCacheInterceptor({ outputDir });
31+
32+
expect(readFileSync(middlewarePath, "utf8")).not.toContain("!NextConfig.experimental?.prefetchInlining");
33+
});
34+
35+
test("serves cached segment data when prefetch inlining is enabled", () => {
36+
const patchedSource = patchCacheInterceptorSource(cacheInterceptorSource);
37+
38+
expect(patchedSource).toContain(
39+
"Boolean(segmentHeader) && segmentHeader in (cachedValue.segmentData || {})"
40+
);
41+
expect(patchedSource).not.toContain("!NextConfig.experimental?.prefetchInlining");
42+
});
43+
44+
test("fails when the upstream cache interceptor no longer matches", () => {
45+
expect(() => patchCacheInterceptorSource("const isSegmentResponse = false;")).toThrow(
46+
"Failed to patch the OpenNext cache interceptor"
47+
);
48+
});
49+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
6+
7+
const segmentPrefetchRule = `
8+
rule:
9+
pattern: Boolean($SEGMENT_HEADER) && $SEGMENT_HEADER in ($CACHED_VALUE.segmentData || {}) && !NextConfig.experimental?.prefetchInlining
10+
fix: Boolean($SEGMENT_HEADER) && $SEGMENT_HEADER in ($CACHED_VALUE.segmentData || {})
11+
`;
12+
13+
export function patchCacheInterceptorSource(source: string): string {
14+
const patchedSource = patchCode(source, segmentPrefetchRule);
15+
if (patchedSource === source) {
16+
throw new Error("Failed to patch the OpenNext cache interceptor");
17+
}
18+
return patchedSource;
19+
}
20+
21+
/**
22+
* OpenNext AWS 4.1.0 treats prefetch inlining as if it eliminates segment responses, but Next.js
23+
* still requests cached route-tree and bundle segments. A full RSC response makes Next.js retry indefinitely.
24+
*/
25+
export function patchCacheInterceptor(buildOpts: Pick<BuildOptions, "outputDir">): void {
26+
const middlewarePath = path.join(buildOpts.outputDir, "middleware/handler.mjs");
27+
const source = readFileSync(middlewarePath, "utf8");
28+
writeFileSync(middlewarePath, patchCacheInterceptorSource(source));
29+
}

0 commit comments

Comments
 (0)