Skip to content

Commit 4966779

Browse files
authoredMar 9, 2025··
Fix for next 15.2 and renderHTML should not be called in minimal mode (#441)
* fix issue * bump next in e2e to 15.2 * added a small test * linting * fix for experimental react * define __NEXT_EXPERIMENTAL_REACT to reduce bundle size * review fix * changeset
1 parent c308aed commit 4966779

File tree

8 files changed

+361
-25
lines changed

8 files changed

+361
-25
lines changed
 

‎.changeset/famous-deers-attack.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
Fix for `Invariant: renderHTML should not be called in minimal mode`

‎packages/cloudflare/src/cli/build/bundle-server.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ import { patchFetchCacheSetMissingWaitUntil } from "./patches/plugins/fetch-cach
1717
import { inlineFindDir } from "./patches/plugins/find-dir.js";
1818
import { patchInstrumentation } from "./patches/plugins/instrumentation.js";
1919
import { inlineLoadManifest } from "./patches/plugins/load-manifest.js";
20+
import { patchNextMinimal } from "./patches/plugins/next-minimal.js";
2021
import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js";
2122
import { patchDepdDeprecations } from "./patches/plugins/patch-depd-deprecations.js";
2223
import { fixRequire } from "./patches/plugins/require.js";
2324
import { shimRequireHook } from "./patches/plugins/require-hook.js";
2425
import { setWranglerExternal } from "./patches/plugins/wrangler-external.js";
25-
import { normalizePath, patchCodeWithValidations } from "./utils/index.js";
26+
import { needsExperimentalReact, normalizePath, patchCodeWithValidations } from "./utils/index.js";
2627

2728
/** The dist directory of the Cloudflare adapter package */
2829
const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -99,6 +100,7 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
99100
inlineLoadManifest(updater, buildOpts),
100101
inlineBuildId(updater),
101102
patchDepdDeprecations(updater),
103+
patchNextMinimal(updater),
102104
// Apply updater updaters, must be the last plugin
103105
updater.plugin,
104106
],
@@ -136,7 +138,12 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
136138
// We make sure that environment variables that Next.js expects are properly defined
137139
"process.env.NEXT_RUNTIME": '"nodejs"',
138140
"process.env.NODE_ENV": '"production"',
139-
"process.env.NEXT_MINIMAL": "true",
141+
// The 2 following defines are used to reduce the bundle size by removing unnecessary code
142+
// Next uses different precompiled renderers (i.e. `app-page.runtime.prod.js`) based on if you use `TURBOPACK` or some experimental React features
143+
// Turbopack is not supported for build at the moment, so we disable it
144+
"process.env.TURBOPACK": "false",
145+
// This define should be safe to use for Next 14.2+, earlier versions (13.5 and less) will cause trouble
146+
"process.env.__NEXT_EXPERIMENTAL_REACT": `${needsExperimentalReact(nextConfig)}`,
140147
},
141148
platform: "node",
142149
banner: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { patchCode } from "../ast/util";
4+
import { abortControllerRule } from "./next-minimal";
5+
6+
const appPageRuntimeProdJs = `let p = new AbortController;
7+
async function h(e3, t3) {
8+
let { flightRouterState: r3, nextUrl: a2, prefetchKind: i2 } = t3, u2 = { [n2.hY]: "1", [n2.B]: encodeURIComponent(JSON.stringify(r3)) };
9+
i2 === o.ob.AUTO && (u2[n2._V] = "1"), a2 && (u2[n2.kO] = a2);
10+
try {
11+
var c2;
12+
let t4 = i2 ? i2 === o.ob.TEMPORARY ? "high" : "low" : "auto";
13+
"export" === process.env.__NEXT_CONFIG_OUTPUT && ((e3 = new URL(e3)).pathname.endsWith("/") ? e3.pathname += "index.txt" : e3.pathname += ".txt");
14+
let r4 = await m(e3, u2, t4, p.signal), a3 = d(r4.url), h2 = r4.redirected ? a3 : void 0, g = r4.headers.get("content-type") || "", v = !!(null == (c2 = r4.headers.get("vary")) ? void 0 : c2.includes(n2.kO)), b = !!r4.headers.get(n2.jc), S = r4.headers.get(n2.UK), _ = null !== S ? parseInt(S, 10) : -1, w = g.startsWith(n2.al);
15+
if ("export" !== process.env.__NEXT_CONFIG_OUTPUT || w || (w = g.startsWith("text/plain")), !w || !r4.ok || !r4.body)
16+
return e3.hash && (a3.hash = e3.hash), f(a3.toString());
17+
let k = b ? function(e4) {
18+
let t5 = e4.getReader();
19+
return new ReadableStream({ async pull(e5) {
20+
for (; ; ) {
21+
let { done: r5, value: n3 } = await t5.read();
22+
if (!r5) {
23+
e5.enqueue(n3);
24+
continue;
25+
}
26+
return;
27+
}
28+
} });
29+
}(r4.body) : r4.body, E = await y(k);
30+
if ((0, l.X)() !== E.b)
31+
return f(r4.url);
32+
return { flightData: (0, s.aj)(E.f), canonicalUrl: h2, couldBeIntercepted: v, prerendered: E.S, postponed: b, staleTime: _ };
33+
} catch (t4) {
34+
return p.signal.aborted || console.error("Failed to fetch RSC payload for " + e3 + ". Falling back to browser navigation.", t4), { flightData: e3.toString(), canonicalUrl: void 0, couldBeIntercepted: false, prerendered: false, postponed: false, staleTime: -1 };
35+
}
36+
}
37+
`;
38+
39+
describe("Abort controller", () => {
40+
test("minimal", () => {
41+
expect(patchCode(appPageRuntimeProdJs, abortControllerRule)).toBe(
42+
`let p = {signal:{aborted: false}};
43+
async function h(e3, t3) {
44+
let { flightRouterState: r3, nextUrl: a2, prefetchKind: i2 } = t3, u2 = { [n2.hY]: "1", [n2.B]: encodeURIComponent(JSON.stringify(r3)) };
45+
i2 === o.ob.AUTO && (u2[n2._V] = "1"), a2 && (u2[n2.kO] = a2);
46+
try {
47+
var c2;
48+
let t4 = i2 ? i2 === o.ob.TEMPORARY ? "high" : "low" : "auto";
49+
"export" === process.env.__NEXT_CONFIG_OUTPUT && ((e3 = new URL(e3)).pathname.endsWith("/") ? e3.pathname += "index.txt" : e3.pathname += ".txt");
50+
let r4 = await m(e3, u2, t4, p.signal), a3 = d(r4.url), h2 = r4.redirected ? a3 : void 0, g = r4.headers.get("content-type") || "", v = !!(null == (c2 = r4.headers.get("vary")) ? void 0 : c2.includes(n2.kO)), b = !!r4.headers.get(n2.jc), S = r4.headers.get(n2.UK), _ = null !== S ? parseInt(S, 10) : -1, w = g.startsWith(n2.al);
51+
if ("export" !== process.env.__NEXT_CONFIG_OUTPUT || w || (w = g.startsWith("text/plain")), !w || !r4.ok || !r4.body)
52+
return e3.hash && (a3.hash = e3.hash), f(a3.toString());
53+
let k = b ? function(e4) {
54+
let t5 = e4.getReader();
55+
return new ReadableStream({ async pull(e5) {
56+
for (; ; ) {
57+
let { done: r5, value: n3 } = await t5.read();
58+
if (!r5) {
59+
e5.enqueue(n3);
60+
continue;
61+
}
62+
return;
63+
}
64+
} });
65+
}(r4.body) : r4.body, E = await y(k);
66+
if ((0, l.X)() !== E.b)
67+
return f(r4.url);
68+
return { flightData: (0, s.aj)(E.f), canonicalUrl: h2, couldBeIntercepted: v, prerendered: E.S, postponed: b, staleTime: _ };
69+
} catch (t4) {
70+
return p.signal.aborted || console.error("Failed to fetch RSC payload for " + e3 + ". Falling back to browser navigation.", t4), { flightData: e3.toString(), canonicalUrl: void 0, couldBeIntercepted: false, prerendered: false, postponed: false, staleTime: -1 };
71+
}
72+
}
73+
`
74+
);
75+
});
76+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { patchCode } from "../ast/util.js";
2+
import { ContentUpdater } from "./content-updater.js";
3+
4+
// We try to be as specific as possible to avoid patching the wrong thing here
5+
// It seems that there is a bug in the worker runtime. When the AbortController is created outside of the request context it throws an error (not sure if it's expected or not) except in this case. https://github.com/cloudflare/workerd/issues/3657
6+
// It fails while requiring the `app-page.runtime.prod.js` file, but instead of throwing an error, it just return an empty object for the `require('app-page.runtime.prod.js')` call which makes every request to an app router page fail.
7+
// If it's a bug in workerd and it's not expected to throw an error, we can remove this patch.
8+
export const abortControllerRule = `
9+
rule:
10+
all:
11+
- kind: lexical_declaration
12+
pattern: let $VAR = new AbortController
13+
- precedes:
14+
kind: function_declaration
15+
stopBy: end
16+
has:
17+
kind: statement_block
18+
has:
19+
kind: try_statement
20+
has:
21+
kind: catch_clause
22+
has:
23+
kind: statement_block
24+
has:
25+
kind: return_statement
26+
all:
27+
- has:
28+
stopBy: end
29+
kind: member_expression
30+
pattern: $VAR.signal.aborted
31+
- has:
32+
stopBy: end
33+
kind: call_expression
34+
regex: console.error\\("Failed to fetch RSC payload for
35+
36+
fix:
37+
'let $VAR = {signal:{aborted: false}};'
38+
`;
39+
40+
// This rule is used instead of defining `process.env.NEXT_MINIMAL` in the `esbuild config.
41+
// Do we want to entirely replace these functions to reduce the bundle size?
42+
// In next `renderHTML` is used as a fallback in case of errors, but in minimal mode it just throws the error and the responsability of handling it is on the infra.
43+
export const nextMinimalRule = `
44+
rule:
45+
kind: member_expression
46+
pattern: process.env.NEXT_MINIMAL
47+
any:
48+
- inside:
49+
kind: parenthesized_expression
50+
stopBy: end
51+
inside:
52+
kind: if_statement
53+
any:
54+
- inside:
55+
kind: statement_block
56+
inside:
57+
kind: method_definition
58+
any:
59+
- has: {kind: property_identifier, field: name, regex: runEdgeFunction}
60+
- has: {kind: property_identifier, field: name, regex: runMiddleware}
61+
- has: {kind: property_identifier, field: name, regex: imageOptimizer}
62+
- has:
63+
kind: statement_block
64+
has:
65+
kind: expression_statement
66+
pattern: res.statusCode = 400;
67+
fix:
68+
'true'
69+
`;
70+
71+
export function patchNextMinimal(updater: ContentUpdater) {
72+
updater.updateContent(
73+
"patch-abortController-next15.2",
74+
{ filter: /app-page(-experimental)?\.runtime\.prod\.js$/, contentFilter: /new AbortController/ },
75+
async ({ contents }) => {
76+
return patchCode(contents, abortControllerRule);
77+
}
78+
);
79+
80+
updater.updateContent(
81+
"patch-next-minimal",
82+
{ filter: /next-server\.(js)$/, contentFilter: /.*/ },
83+
async ({ contents }) => {
84+
return patchCode(contents, nextMinimalRule);
85+
}
86+
);
87+
88+
return {
89+
name: "patch-abortController",
90+
setup() {},
91+
};
92+
}

‎packages/cloudflare/src/cli/build/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export * from "./apply-patches.js";
22
export * from "./create-config-files.js";
33
export * from "./ensure-cf-config.js";
44
export * from "./extract-project-env-vars.js";
5+
export * from "./needs-experimental-react.js";
56
export * from "./normalize-path.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { NextConfig } from "@opennextjs/aws/types/next-types";
2+
3+
// Not sure if this should be upstreamed to aws
4+
// Adding more stuff there make typing incorrect actually, these properties are never undefined as long as it is the right version of next
5+
// Ideally we'd have different `NextConfig` types for different versions of next
6+
interface ExtendedNextConfig extends NextConfig {
7+
experimental: {
8+
ppr?: boolean;
9+
taint?: boolean;
10+
viewTransition?: boolean;
11+
serverActions?: boolean;
12+
};
13+
}
14+
15+
// Copied from https://github.com/vercel/next.js/blob/4518bc91641a0fd938664b781e12ae7c145f3396/packages/next/src/lib/needs-experimental-react.ts#L3-L6
16+
export function needsExperimentalReact(nextConfig: ExtendedNextConfig) {
17+
const { ppr, taint, viewTransition } = nextConfig.experimental || {};
18+
return Boolean(ppr || taint || viewTransition);
19+
}

‎pnpm-lock.yaml

+158-22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pnpm-workspace.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ catalogs:
4242
"@types/react-dom": 19.0.0
4343
"@types/react": 19.0.0
4444
autoprefixer: 10.4.15
45-
next: 15.1.0
45+
next: 15.2.0
4646
postcss: 8.4.27
4747
react-dom: 19.0.0
4848
react: 19.0.0

0 commit comments

Comments
 (0)
Please sign in to comment.