From e0c0eeae21be0157bfc496b41e1e1353473872dc Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 1 Aug 2026 16:38:39 +1000
Subject: [PATCH 01/14] fix(cache): support Cache Components on Workers
Dynamic partial prerenders could hang because Next\x27s atomic timer scheduler depends on private Node timer state that workerd does not expose. Cache interception could also finalize a cached PPR shell before Next resumed postponed work.
Preserve staged render ordering with the unpatched immediate scheduler, and bypass interception only for partially prerendered routes so Next produces the complete stream. Cover dynamic params, warm caches, prefetches, and client navigation.
---
.changeset/bright-caches-stream.md | 7 +
examples/e2e/experimental/e2e/ppr.test.ts | 53 ++++++
examples/e2e/experimental/open-next.config.ts | 1 +
.../experimental/src/app/ppr/[slug]/page.tsx | 30 ++++
packages/cloudflare/src/api/config.ts | 2 +-
packages/cloudflare/src/cli/build/build.ts | 2 +
.../cloudflare/src/cli/build/bundle-server.ts | 2 +
.../patches/plugins/cache-components.spec.ts | 152 ++++++++++++++++++
.../build/patches/plugins/cache-components.ts | 146 +++++++++++++++++
9 files changed, 394 insertions(+), 1 deletion(-)
create mode 100644 .changeset/bright-caches-stream.md
create mode 100644 examples/e2e/experimental/src/app/ppr/[slug]/page.tsx
create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md
new file mode 100644
index 000000000..857d702ea
--- /dev/null
+++ b/.changeset/bright-caches-stream.md
@@ -0,0 +1,7 @@
+---
+"@opennextjs/cloudflare": patch
+---
+
+fix: support Cache Components rendering on Workers
+
+Use a workerd-compatible staged render scheduler and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response.
diff --git a/examples/e2e/experimental/e2e/ppr.test.ts b/examples/e2e/experimental/e2e/ppr.test.ts
index 369c02a9d..78697b196 100644
--- a/examples/e2e/experimental/e2e/ppr.test.ts
+++ b/examples/e2e/experimental/e2e/ppr.test.ts
@@ -12,6 +12,9 @@ test.describe("PPR", () => {
});
test("PPR rsc prefetch request should be cached", async ({ request }) => {
+ await request.get("/ppr", {
+ headers: { rsc: "1", "next-router-prefetch": "1" },
+ });
const resp = await request.get("/ppr", {
headers: { rsc: "1", "next-router-prefetch": "1" },
});
@@ -21,4 +24,54 @@ test.describe("PPR", () => {
expect(headers["x-nextjs-cache"]).toEqual("HIT");
expect(headers["cache-control"]).toEqual("s-maxage=31536000");
});
+
+ test("dynamic PPR fallback should resume with route params", async ({ page }) => {
+ const response = await page.goto("/ppr/first");
+
+ expect(response?.status()).toEqual(200);
+ await expect(page.getByTestId("static-shell")).toBeVisible();
+ await expect(page.getByTestId("dynamic-slug")).toHaveText("Dynamic slug: first");
+ });
+
+ test("dynamic PPR responses stream the shell and resumed content on cold and warm requests", async ({
+ request,
+ }) => {
+ for (const path of ["/ppr/first", "/ppr/first", "/ppr/second"]) {
+ const response = await request.get(path);
+ const body = await response.text();
+
+ expect(response.status()).toEqual(200);
+ expect(body).toContain("Static shell");
+ expect(body.replaceAll("", "")).toContain(`Dynamic slug: ${path.split("/").at(-1)}`);
+ expect(body).toContain("self.__next_f.push");
+ expect(body).toMatch(/\$(?:RC|RS|RX)\b/);
+ }
+ });
+
+ test("dynamic PPR supports route and segment prefetch requests", async ({ request }) => {
+ for (const headers of [
+ { rsc: "1", "next-router-prefetch": "1" },
+ {
+ rsc: "1",
+ "next-router-prefetch": "1",
+ "next-router-segment-prefetch": "/_tree",
+ },
+ ]) {
+ const response = await request.get("/ppr/first", { headers });
+
+ expect(response.status()).toEqual(200);
+ expect(response.headers()["content-type"]).toContain("text/x-component");
+ expect((await response.body()).byteLength).toBeGreaterThan(0);
+ }
+ });
+
+ test("client navigation can transition between dynamic PPR params", async ({ page }) => {
+ await page.goto("/ppr/first");
+ await expect(page.getByTestId("dynamic-slug")).toHaveText("Dynamic slug: first");
+
+ await page.getByRole("link", { name: "Second item" }).click();
+ await page.waitForURL("/ppr/second");
+ await expect(page.getByText("Dynamic slug: second", { exact: true })).toBeVisible();
+ expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toEqual(1);
+ });
});
diff --git a/examples/e2e/experimental/open-next.config.ts b/examples/e2e/experimental/open-next.config.ts
index ba0aacef0..c14e30462 100644
--- a/examples/e2e/experimental/open-next.config.ts
+++ b/examples/e2e/experimental/open-next.config.ts
@@ -5,6 +5,7 @@ import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
+ enableCacheInterception: true,
// With such a configuration, we could have up to 12 * (8 + 2) = 120 Durable Objects instances
tagCache: shardedTagCache({
baseShardSize: 12,
diff --git a/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx b/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx
new file mode 100644
index 000000000..55d7d3352
--- /dev/null
+++ b/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx
@@ -0,0 +1,30 @@
+import { headers } from "next/headers";
+import Link from "next/link";
+import { setTimeout } from "node:timers/promises";
+import { Suspense } from "react";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+};
+
+async function DynamicSlug({ params }: PageProps) {
+ const [{ slug }] = await Promise.all([params, headers()]);
+ await setTimeout(100);
+
+ return
Dynamic slug: {slug}
;
+}
+
+export default function DynamicPPRPage({ params }: PageProps) {
+ return (
+
+ Static shell
+
+ Loading dynamic slug...}>
+
+
+
+ );
+}
diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts
index d625f8bd8..330832eae 100644
--- a/packages/cloudflare/src/api/config.ts
+++ b/packages/cloudflare/src/api/config.ts
@@ -44,7 +44,7 @@ export type CloudflareOverrides = {
/**
* Enable cache interception
- * Should be `false` when PPR is used
+ * Partially prerendered routes bypass interception so Next.js can resume their postponed work.
* @default false
*/
enableCacheInterception?: boolean;
diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts
index 9e80ec3bc..1e807ee73 100644
--- a/packages/cloudflare/src/cli/build/build.ts
+++ b/packages/cloudflare/src/cli/build/build.ts
@@ -19,6 +19,7 @@ import { compileInit } from "./open-next/compile-init.js";
import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
import { createServerBundle } from "./open-next/createServerBundle.js";
+import { patchMiddlewareCacheComponents } from "./patches/plugins/cache-components.js";
import { useNodeMiddleware } from "./utils/middleware.js";
import { getVersion } from "./utils/version.js";
@@ -100,6 +101,7 @@ export async function build(
// Compile middleware
await createMiddleware(options, { forceOnlyBuildOnce: true });
+ patchMiddlewareCacheComponents(options);
createStaticAssets(options, { useBasePath: true });
diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts
index 9e1cad3e5..3851d7778 100644
--- a/packages/cloudflare/src/cli/build/bundle-server.ts
+++ b/packages/cloudflare/src/cli/build/bundle-server.ts
@@ -13,6 +13,7 @@ import type { ProjectOptions } from "../project-options.js";
import { normalizePath } from "../utils/normalize-path.js";
import { patchVercelOgLibrary } from "./patches/ast/patch-vercel-og-library.js";
import { patchWebpackRuntime } from "./patches/ast/webpack-runtime.js";
+import { patchCacheComponents } from "./patches/plugins/cache-components.js";
import { inlineDynamicRequires } from "./patches/plugins/dynamic-requires.js";
import { inlineFindDir } from "./patches/plugins/find-dir.js";
import { patchInstrumentation } from "./patches/plugins/instrumentation.js";
@@ -102,6 +103,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project
fixRequire(updater),
handleOptionalDependencies(optionalDependencies),
patchInstrumentation(updater, buildOpts),
+ patchCacheComponents(updater),
patchPagesRouterContext(buildOpts),
inlineFindDir(updater, buildOpts),
inlineLoadManifest(updater, buildOpts),
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
new file mode 100644
index 000000000..9b1d07509
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
@@ -0,0 +1,152 @@
+import { readFileSync } from "node:fs";
+
+import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
+import mockFs from "mock-fs";
+import { afterEach, describe, expect, test } from "vitest";
+
+import { computePatchDiff } from "../../utils/test-patch.js";
+import {
+ bypassPprCacheInterceptionRule,
+ patchMiddlewareCacheComponents,
+ runInSequentialTasksRule,
+} from "./cache-components.js";
+
+describe("Cache Components", () => {
+ afterEach(() => mockFs.restore());
+
+ test("uses a workerd-compatible sequential task scheduler", () => {
+ const code = `let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
+function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[];
+s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}}));
+for(let e=0;er()))}
+s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`;
+
+ expect(computePatchDiff("app-render-render-utils.js", code, runInSequentialTasksRule))
+ .toMatchInlineSnapshot(`
+ "Index: app-render-render-utils.js
+ ===================================================================
+ --- app-render-render-utils.js
+ +++ app-render-render-utils.js
+ @@ -1,5 +1,48 @@
+ let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
+ -function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[];
+ -s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}}));
+ -for(let e=0;er()))}
+ -s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}
+ \\ No newline at end of file
+ +function oJ(first, ...rest) {
+ + const workerdFastSetImmediate = require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
+ + return new Promise((resolve, reject) => {
+ + let result;
+ + let failed = false;
+ +
+ + function fail(err) {
+ + failed = true;
+ + reject(err);
+ + }
+ +
+ + function scheduleRest(index) {
+ + (0, workerdFastSetImmediate.unpatchedSetImmediate)(() => {
+ + if (failed) return;
+ +
+ + try {
+ + if (index === rest.length) {
+ + (0, workerdFastSetImmediate.expectNoPendingImmediates)();
+ + resolve(result);
+ + return;
+ + }
+ +
+ + scheduleRest(index + 1);
+ + (0, workerdFastSetImmediate.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)();
+ + rest[index]();
+ + } catch (err) {
+ + fail(err);
+ + }
+ + });
+ + }
+ +
+ + setTimeout(() => {
+ + if (failed) return;
+ +
+ + try {
+ + scheduleRest(0);
+ + (0, workerdFastSetImmediate.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)();
+ + result = first();
+ + if (result && typeof result.then === "function") {
+ + result.then(() => {}, () => {});
+ + }
+ + } catch (err) {
+ + fail(err);
+ + }
+ + });
+ + });
+ +}
+ \\ No newline at end of file
+ "
+ `);
+ });
+
+ test("leaves partially prerendered routes for Next.js to resume", () => {
+ const code = `export async function cacheInterceptor(event) {
+ let localizedPath = event.rawPath;
+ const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
+ Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
+ if (isISR) {
+ const cachedData = await globalThis.incrementalCache.get(localizedPath);
+ if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value);
+ }
+ return event;
+}`;
+
+ expect(computePatchDiff("cacheInterceptor.js", code, bypassPprCacheInterceptionRule))
+ .toMatchInlineSnapshot(`
+ "Index: cacheInterceptor.js
+ ===================================================================
+ --- cacheInterceptor.js
+ +++ cacheInterceptor.js
+ @@ -1,10 +1,14 @@
+ export async function cacheInterceptor(event) {
+ let localizedPath = event.rawPath;
+ const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
+ Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
+ - if (isISR) {
+ - const cachedData = await globalThis.incrementalCache.get(localizedPath);
+ - if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value);
+ - }
+ + if (isISR && !(
+ + PrerenderManifest?.routes?.[localizedPath]?.renderingMode === "PARTIALLY_STATIC" ||
+ + PrerenderManifest?.routes?.[localizedPath]?.experimentalPPR === true ||
+ + Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((route) =>
+ + new RegExp(route.routeRegex).test(localizedPath) &&
+ + (route.renderingMode === "PARTIALLY_STATIC" || route.experimentalPPR === true)
+ + )
+ +)) { const cachedData = await globalThis.incrementalCache.get(localizedPath);if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value); }
+ return event;
+ }
+ \\ No newline at end of file
+ "
+ `);
+ });
+
+ test("patches cache interception in the generated external middleware", () => {
+ mockFs({
+ "/output/middleware/handler.mjs": `export async function cacheInterceptor(event) {
+ let localizedPath = event.rawPath;
+ const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath);
+ if (isISR) {
+ return generateResult(event);
+ }
+ return event;
+}`,
+ });
+
+ patchMiddlewareCacheComponents({
+ outputDir: "/output",
+ config: { dangerous: { enableCacheInterception: true } },
+ } as BuildOptions);
+
+ expect(readFileSync("/output/middleware/handler.mjs", "utf8")).toContain(
+ 'route.renderingMode === "PARTIALLY_STATIC"'
+ );
+ });
+});
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
new file mode 100644
index 000000000..500d97053
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
@@ -0,0 +1,146 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
+import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
+import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js";
+import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
+
+/**
+ * Next.js stages Cache Components renders with timers that it forces into the same Node.js timer
+ * phase by mutating their private `_idleStart` field. workerd timer handles do not expose that
+ * field and workerd may run an immediate between two timers, so the staged render can stall.
+ *
+ * Reserve the next stage before running the current one, then use Next's original setImmediate to
+ * enter it. Next's fast-immediate patch drains next ticks, microtasks, and captured immediates before
+ * the reserved stage runs. Reserving it first also prevents immediates scheduled by render code from
+ * overtaking the next stage.
+ */
+export function patchCacheComponents(updater: ContentUpdater): Plugin {
+ updater.updateContent("cache-components-scheduler", [
+ {
+ filter: getCrossPlatformPathRegex(
+ String.raw`/next/dist/compiled/next-server/app-page(?:-experimental)?\.runtime\.prod\.js$`,
+ { escape: false }
+ ),
+ contentFilter: /current runtime's implementation of [`"]setTimeout\(\)[`"]/,
+ callback: async ({ contents }) => patchCode(contents, runInSequentialTasksRule),
+ },
+ ]);
+
+ return {
+ name: "patch-cache-components",
+ setup() {},
+ };
+}
+
+/**
+ * Cache interception is compiled into the external middleware before the server bundle plugins run,
+ * so patch its generated output at the boundary where Cloudflare takes ownership of the AWS build.
+ */
+export function patchMiddlewareCacheComponents(buildOpts: BuildOptions): void {
+ if (buildOpts.config.dangerous?.enableCacheInterception !== true) {
+ return;
+ }
+
+ const middlewarePath = path.join(buildOpts.outputDir, "middleware", "handler.mjs");
+ if (!existsSync(middlewarePath)) {
+ throw new Error("Cannot patch cache interception because the middleware bundle is missing");
+ }
+
+ const contents = readFileSync(middlewarePath, "utf8");
+ if (!contents.includes("async function cacheInterceptor(")) {
+ throw new Error("Cannot find cache interception in the generated middleware bundle");
+ }
+
+ const patchedContents = patchCode(contents, bypassPprCacheInterceptionRule);
+ if (patchedContents === contents) {
+ throw new Error("Failed to patch cache interception for Cache Components routes");
+ }
+
+ writeFileSync(middlewarePath, patchedContents);
+}
+
+export const runInSequentialTasksRule = `
+rule:
+ pattern:
+ selector: function_declaration
+ context: "function $FUNCTION($$$ARGS) { $$$BODY }"
+ all:
+ - has:
+ regex: createAtomicTimerGroup
+ stopBy: end
+ - has:
+ regex: DANGEROUSLY_runPendingImmediatesAfterCurrentTask
+ stopBy: end
+fix: |-
+ function $FUNCTION(first, ...rest) {
+ const workerdFastSetImmediate = require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
+ return new Promise((resolve, reject) => {
+ let result;
+ let failed = false;
+
+ function fail(err) {
+ failed = true;
+ reject(err);
+ }
+
+ function scheduleRest(index) {
+ (0, workerdFastSetImmediate.unpatchedSetImmediate)(() => {
+ if (failed) return;
+
+ try {
+ if (index === rest.length) {
+ (0, workerdFastSetImmediate.expectNoPendingImmediates)();
+ resolve(result);
+ return;
+ }
+
+ scheduleRest(index + 1);
+ (0, workerdFastSetImmediate.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)();
+ rest[index]();
+ } catch (err) {
+ fail(err);
+ }
+ });
+ }
+
+ setTimeout(() => {
+ if (failed) return;
+
+ try {
+ scheduleRest(0);
+ (0, workerdFastSetImmediate.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)();
+ result = first();
+ if (result && typeof result.then === "function") {
+ result.then(() => {}, () => {});
+ }
+ } catch (err) {
+ fail(err);
+ }
+ });
+ });
+ }
+`;
+
+/**
+ * The cache interceptor can only return the cached PPR shell. It does not have the postponed state
+ * that Next.js needs to resume a Cache Components render, so these routes must reach Next's request
+ * handler. Other ISR routes keep using cache interception.
+ */
+export const bypassPprCacheInterceptionRule = `
+rule:
+ pattern: if (isISR) { $$$BODY }
+ inside:
+ pattern: async function cacheInterceptor($$$ARGS) { $$$FUNCTION_BODY }
+ stopBy: end
+fix: |-
+ if (isISR && !(
+ PrerenderManifest?.routes?.[localizedPath]?.renderingMode === "PARTIALLY_STATIC" ||
+ PrerenderManifest?.routes?.[localizedPath]?.experimentalPPR === true ||
+ Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((route) =>
+ new RegExp(route.routeRegex).test(localizedPath) &&
+ (route.renderingMode === "PARTIALLY_STATIC" || route.experimentalPPR === true)
+ )
+ )) { $$$BODY }
+`;
From 90be2df7e33ea8ed1b164b576e67d64299b202a3 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:28:13 +1000
Subject: [PATCH 02/14] fix(cache): patch minified Cache Components schedulers
Turbo app-page runtimes strip createAtomicTimerGroup, causing the scheduler patch to silently no-op and Workers PPR requests to hang. Match structural timer mutation and fast-immediate markers across compiled runtimes and generated server chunks, and fail the build if the incompatible path survives.\n\nReal minified fixtures cover stable and canary output.
---
.../patches/plugins/cache-components.spec.ts | 65 ++++++++++++++++++-
.../build/patches/plugins/cache-components.ts | 60 ++++++++++++++---
...xt-16.2.12-app-page-turbo.runtime.prod.txt | 1 +
...p-page-turbo-experimental.runtime.prod.txt | 1 +
4 files changed, 117 insertions(+), 10 deletions(-)
create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt
create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
index 9b1d07509..6812ed30e 100644
--- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
@@ -7,16 +7,25 @@ import { afterEach, describe, expect, test } from "vitest";
import { computePatchDiff } from "../../utils/test-patch.js";
import {
bypassPprCacheInterceptionRule,
+ cacheComponentsSchedulerFileFilter,
+ patchCacheComponentsScheduler,
patchMiddlewareCacheComponents,
runInSequentialTasksRule,
} from "./cache-components.js";
+const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/;
+
+function readSchedulerFixture(name: string): string {
+ return readFileSync(new URL(`./fixtures/cache-components/${name}`, import.meta.url), "utf8");
+}
+
describe("Cache Components", () => {
afterEach(() => mockFs.restore());
test("uses a workerd-compatible sequential task scheduler", () => {
const code = `let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[];
+if("_idleStart"in s)s._idleStart=0;
s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}}));
for(let e=0;er()))}
s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`;
@@ -27,9 +36,10 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`;
===================================================================
--- app-render-render-utils.js
+++ app-render-render-utils.js
- @@ -1,5 +1,48 @@
+ @@ -1,6 +1,48 @@
let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
-function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[];
+ -if("_idleStart"in s)s._idleStart=0;
-s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}}));
-for(let e=0;er()))}
-s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}
@@ -86,6 +96,59 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`;
`);
});
+ test.each([
+ "next-16.2.12-app-page-turbo.runtime.prod.txt",
+ "next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt",
+ ])("patches the minified Turbo scheduler from %s", (fixture) => {
+ const code = readSchedulerFixture(fixture);
+ const patched = patchCacheComponentsScheduler(code, fixture);
+
+ expect(patched).not.toBe(code);
+ expect(patched).not.toMatch(incompatibleSchedulerPattern);
+ expect(patched).toContain("workerdFastSetImmediate.unpatchedSetImmediate");
+ });
+
+ test.each([
+ "/next/dist/compiled/next-server/app-page.runtime.prod.js",
+ "/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js",
+ "/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js",
+ "/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js",
+ "/app/.next/server/chunks/ssr/[root-of-the-server]__abc._.js",
+ "/app/.next/server/chunks/214.js",
+ ])("targets the Cache Components runtime %s", (runtimePath) => {
+ expect(cacheComponentsSchedulerFileFilter.test(runtimePath)).toBe(true);
+ });
+
+ test("removes the separate atomic timer group emitted by webpack", () => {
+ const code = `function createGroup(){return function schedule(callback){
+ const timer=setTimeout(callback,0);
+ if("_idleStart" in timer)timer._idleStart=0;
+ return timer;
+}}
+function run(first,...rest){return new Promise((resolve)=>{
+ const schedule=createAtomicTimerGroup();
+ schedule(()=>DANGEROUSLY_runPendingImmediatesAfterCurrentTask());
+ schedule(()=>resolve(first()));
+})}`;
+
+ const patched = patchCacheComponentsScheduler(code, "webpack-server-chunk.js");
+
+ expect(patched).not.toMatch(incompatibleSchedulerPattern);
+ expect(patched).toContain("OpenNext replaced this incompatible Cache Components timer group");
+ expect(patched).toContain("workerdFastSetImmediate.unpatchedSetImmediate");
+ });
+
+ test("fails when an incompatible scheduler is present but cannot be patched", () => {
+ const code = `function changedScheduler(){
+ const timer = setTimeout(() => {}, 0);
+ if ("_idleStart" in timer) timer._idleStart = 0;
+}`;
+
+ expect(() => patchCacheComponentsScheduler(code, "changed-runtime.js")).toThrow(
+ "Failed to patch the Cache Components scheduler in changed-runtime.js"
+ );
+ });
+
test("leaves partially prerendered routes for Next.js to resume", () => {
const code = `export async function cacheInterceptor(event) {
let localizedPath = event.rawPath;
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
index 500d97053..0a2412d57 100644
--- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
@@ -6,6 +6,13 @@ import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js";
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
+const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/;
+
+export const cacheComponentsSchedulerFileFilter = getCrossPlatformPathRegex(
+ String.raw`/(?:next/dist/compiled/next-server/app-page(?:-turbo)?(?:-experimental)?\.runtime\.prod|\.next/server/chunks/.+)\.js$`,
+ { escape: false }
+);
+
/**
* Next.js stages Cache Components renders with timers that it forces into the same Node.js timer
* phase by mutating their private `_idleStart` field. workerd timer handles do not expose that
@@ -19,12 +26,10 @@ import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
export function patchCacheComponents(updater: ContentUpdater): Plugin {
updater.updateContent("cache-components-scheduler", [
{
- filter: getCrossPlatformPathRegex(
- String.raw`/next/dist/compiled/next-server/app-page(?:-experimental)?\.runtime\.prod\.js$`,
- { escape: false }
- ),
- contentFilter: /current runtime's implementation of [`"]setTimeout\(\)[`"]/,
- callback: async ({ contents }) => patchCode(contents, runInSequentialTasksRule),
+ filter: cacheComponentsSchedulerFileFilter,
+ contentFilter: incompatibleSchedulerPattern,
+ callback: async ({ contents, path: runtimePath }) =>
+ patchCacheComponentsScheduler(contents, runtimePath),
},
]);
@@ -34,6 +39,20 @@ export function patchCacheComponents(updater: ContentUpdater): Plugin {
};
}
+export function patchCacheComponentsScheduler(contents: string, runtimePath: string): string {
+ const patchedScheduler = patchCode(contents, runInSequentialTasksRule);
+ if (patchedScheduler === contents) {
+ throw new Error(`Failed to patch the Cache Components scheduler in ${runtimePath}`);
+ }
+
+ const patchedContents = patchCode(patchedScheduler, disableAtomicTimerGroupRule);
+ if (incompatibleSchedulerPattern.test(patchedContents)) {
+ throw new Error(`Failed to patch the Cache Components scheduler in ${runtimePath}`);
+ }
+
+ return patchedContents;
+}
+
/**
* Cache interception is compiled into the external middleware before the server bundle plugins run,
* so patch its generated output at the boundary where Cloudflare takes ownership of the AWS build.
@@ -67,12 +86,16 @@ rule:
selector: function_declaration
context: "function $FUNCTION($$$ARGS) { $$$BODY }"
all:
- - has:
- regex: createAtomicTimerGroup
- stopBy: end
- has:
regex: DANGEROUSLY_runPendingImmediatesAfterCurrentTask
stopBy: end
+ - any:
+ - has:
+ regex: '["'']_idleStart["'']\\s*in'
+ stopBy: end
+ - has:
+ regex: createAtomicTimerGroup
+ stopBy: end
fix: |-
function $FUNCTION(first, ...rest) {
const workerdFastSetImmediate = require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js");
@@ -123,6 +146,25 @@ fix: |-
}
`;
+/**
+ * Webpack emits the atomic timer group and sequential-task runner as separate modules. The runner
+ * is replaced above, so leave a fail-fast guard in the now-unreachable timer-group implementation
+ * instead of shipping workerd-incompatible `_idleStart` mutation code.
+ */
+export const disableAtomicTimerGroupRule = `
+rule:
+ pattern:
+ selector: function_declaration
+ context: "function $FUNCTION($$$ARGS) { $$$BODY }"
+ has:
+ regex: '["'']_idleStart["'']\\s*in'
+ stopBy: end
+fix: |-
+ function $FUNCTION() {
+ throw new Error("OpenNext replaced this incompatible Cache Components timer group");
+ }
+`;
+
/**
* The cache interceptor can only return the cached PPR shell. It does not have the postponed state
* that Next.js needs to resume a Cache Components render, so these routes must reach Next's request
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt
new file mode 100644
index 000000000..7cdd93c49
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt
@@ -0,0 +1 @@
+let sB=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js"),sq=!0;function sz(){console.warn("Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/")}function sX(){}function sV(e,...t){return new Promise((r,n)=>{let a,i=function(e=0){{let n=!0,a=null,i=!1,o=!1;function t(e){return i=!0,sq&&(0,sB.unpatchedSetImmediate)(()=>{o=!0}),e()}function r(e){return sq&&o&&(sq=!1,sz()),e()}return function(o){if(i)throw Object.defineProperty(new eB.z("Cannot schedule more timers into a group that already executed"),"__NEXT_ERROR_CODE",{value:"E935",enumerable:!1,configurable:!0});let s=setTimeout(n?t:r,e,o);if(n=!1,!sq)return s;try{"_idleStart"in s&&"number"==typeof s._idleStart?null===a?a=s._idleStart:s._idleStart=a:(sq=!1,sz())}catch(e){console.error(Object.defineProperty(new eB.z("An unexpected error occurred while adjusting `_idleStart` on an atomic timer",{cause:e}),"__NEXT_ERROR_CODE",{value:"E933",enumerable:!1,configurable:!0})),sq=!1,sz()}return s}}}(),o=[];o.push(i(()=>{try{(0,sB.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e(),(0,so.Q)(a)&&a.then(sX,sX)}catch(e){for(let e=1;e{try{(0,sB.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),r()}catch(e){for(;++a{try{(0,sB.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt
new file mode 100644
index 000000000..345605ceb
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt
@@ -0,0 +1 @@
+let iy=!0;function iv(){console.warn("Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/")}function ib(){}function iS(e,...t){return new Promise((r,n)=>{let a,i=function(e=0){{let n=!0,a=null,i=!1,s=!1;function t(e){return i=!0,iy&&(0,nZ.unpatchedSetImmediate)(()=>{s=!0}),e()}function r(e){return iy&&s&&(iy=!1,iv()),e()}return function(s){if(i)throw Object.defineProperty(new ey.z("Cannot schedule more timers into a group that already executed"),"__NEXT_ERROR_CODE",{value:"E935",enumerable:!1,configurable:!0});let o=setTimeout(n?t:r,e,s);if(n=!1,!iy)return o;try{"_idleStart"in o&&"number"==typeof o._idleStart?null===a?a=o._idleStart:o._idleStart=a:(iy=!1,iv())}catch(e){console.error(Object.defineProperty(new ey.z("An unexpected error occurred while adjusting `_idleStart` on an atomic timer",{cause:e}),"__NEXT_ERROR_CODE",{value:"E933",enumerable:!1,configurable:!0})),iy=!1,iv()}return o}}}(),s=[];s.push(i(()=>{try{(0,nZ.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e(),(0,aN.Q)(a)&&a.then(ib,ib)}catch(e){for(let e=1;e{try{(0,nZ.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),r()}catch(e){for(;++a{try{(0,nZ.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}
From e70f0e5174703269e2f6ca5cafc6fdc83b6dde2a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:55:17 +1000
Subject: [PATCH 03/14] fix(cache): preserve unrelated timer inspection code
Application and dependency chunks can contain their own private timer feature checks. Identify Next's atomic timer group by its scheduling invariant and setTimeout structure so unrelated functions remain untouched while the incompatible scheduler still fails closed.
---
.../patches/plugins/cache-components.spec.ts | 11 ++++++++---
.../build/patches/plugins/cache-components.ts | 19 +++++++++++++------
2 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
index 6812ed30e..eda7f8b0f 100644
--- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts
@@ -120,7 +120,9 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`;
});
test("removes the separate atomic timer group emitted by webpack", () => {
- const code = `function createGroup(){return function schedule(callback){
+ const unrelatedIdleStartCheck = `function inspectTimer(timer){return "_idleStart" in timer?timer._idleStart:null}`;
+ const code = `function createGroup(){let didRun=false;return function schedule(callback){
+ if(didRun)throw new Error("Cannot schedule more timers into a group that already executed");
const timer=setTimeout(callback,0);
if("_idleStart" in timer)timer._idleStart=0;
return timer;
@@ -129,17 +131,20 @@ function run(first,...rest){return new Promise((resolve)=>{
const schedule=createAtomicTimerGroup();
schedule(()=>DANGEROUSLY_runPendingImmediatesAfterCurrentTask());
schedule(()=>resolve(first()));
-})}`;
+})}
+${unrelatedIdleStartCheck}`;
const patched = patchCacheComponentsScheduler(code, "webpack-server-chunk.js");
- expect(patched).not.toMatch(incompatibleSchedulerPattern);
+ expect(patched).not.toContain("Cannot schedule more timers into a group that already executed");
+ expect(patched).toContain(unrelatedIdleStartCheck);
expect(patched).toContain("OpenNext replaced this incompatible Cache Components timer group");
expect(patched).toContain("workerdFastSetImmediate.unpatchedSetImmediate");
});
test("fails when an incompatible scheduler is present but cannot be patched", () => {
const code = `function changedScheduler(){
+ if (didRun) throw new Error("Cannot schedule more timers into a group that already executed");
const timer = setTimeout(() => {}, 0);
if ("_idleStart" in timer) timer._idleStart = 0;
}`;
diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
index 0a2412d57..751642250 100644
--- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
+++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts
@@ -6,7 +6,7 @@ import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js";
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
-const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/;
+const atomicTimerGroupErrorPattern = /Cannot schedule more timers into a group that already executed/;
export const cacheComponentsSchedulerFileFilter = getCrossPlatformPathRegex(
String.raw`/(?:next/dist/compiled/next-server/app-page(?:-turbo)?(?:-experimental)?\.runtime\.prod|\.next/server/chunks/.+)\.js$`,
@@ -27,7 +27,7 @@ export function patchCacheComponents(updater: ContentUpdater): Plugin {
updater.updateContent("cache-components-scheduler", [
{
filter: cacheComponentsSchedulerFileFilter,
- contentFilter: incompatibleSchedulerPattern,
+ contentFilter: atomicTimerGroupErrorPattern,
callback: async ({ contents, path: runtimePath }) =>
patchCacheComponentsScheduler(contents, runtimePath),
},
@@ -46,7 +46,7 @@ export function patchCacheComponentsScheduler(contents: string, runtimePath: str
}
const patchedContents = patchCode(patchedScheduler, disableAtomicTimerGroupRule);
- if (incompatibleSchedulerPattern.test(patchedContents)) {
+ if (atomicTimerGroupErrorPattern.test(patchedContents)) {
throw new Error(`Failed to patch the Cache Components scheduler in ${runtimePath}`);
}
@@ -156,9 +156,16 @@ rule:
pattern:
selector: function_declaration
context: "function $FUNCTION($$$ARGS) { $$$BODY }"
- has:
- regex: '["'']_idleStart["'']\\s*in'
- stopBy: end
+ all:
+ - has:
+ regex: '["'']_idleStart["'']\\s*in'
+ stopBy: end
+ - has:
+ regex: Cannot schedule more timers into a group that already executed
+ stopBy: end
+ - has:
+ regex: '\\bsetTimeout\\s*\\('
+ stopBy: end
fix: |-
function $FUNCTION() {
throw new Error("OpenNext replaced this incompatible Cache Components timer group");
From 570867500de11bf55a8089fc639182aa678e7d46 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 1 Aug 2026 19:38:35 +1000
Subject: [PATCH 04/14] fix(cache): scope the module loading signal to its
owning request
Next.js tracks in-flight dynamic imports on one CacheSignal per process.
On Workers that signal holds immediate/timeout cleanup handles owned by
whichever request scheduled them, so an overlapping request clearing them
dies with "Cannot perform I/O on behalf of a different request" mid
render and the isolate keeps serving truncated responses. Key the signal
on the per-request Cloudflare context instead.
---
.changeset/bright-caches-stream.md | 2 +
.../experimental/e2e/concurrent-rsc.test.ts | 112 ++++++++++++++++++
.../src/app/tracked-import/[slug]/page.tsx | 30 +++++
.../e2e/experimental/src/lib/late-module.ts | 3 +
.../patches/plugins/cache-components.spec.ts | 56 +++++++++
.../build/patches/plugins/cache-components.ts | 54 +++++++++
6 files changed, 257 insertions(+)
create mode 100644 examples/e2e/experimental/e2e/concurrent-rsc.test.ts
create mode 100644 examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx
create mode 100644 examples/e2e/experimental/src/lib/late-module.ts
diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md
index 857d702ea..ad6fb319c 100644
--- a/.changeset/bright-caches-stream.md
+++ b/.changeset/bright-caches-stream.md
@@ -5,3 +5,5 @@
fix: support Cache Components rendering on Workers
Use a workerd-compatible staged render scheduler and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response.
+
+Also scope the module loading `CacheSignal` to the request that owns its timer handles. Next.js keeps one signal per process, but on Workers its immediate/timeout cleanup handles belong to the request that scheduled them, so an overlapping request clearing them fails with "Cannot perform I/O on behalf of a different request" and the response streams stay incomplete.
diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts
new file mode 100644
index 000000000..a8d8132e5
--- /dev/null
+++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts
@@ -0,0 +1,112 @@
+import { expect, test, type APIRequestContext } from "@playwright/test";
+
+/**
+ * Cache Components state that Next.js keeps per process is shared by every request in a Worker
+ * isolate. When such state holds request bound I/O handles, an overlapping request clears a handle it
+ * does not own, workerd rejects it with "Cannot perform I/O on behalf of a different request", and the
+ * throw escapes mid render — the response never completes and the isolate keeps serving truncated
+ * bodies afterwards. Overlapping route and segment RSC prefetches are what a browser issues while
+ * hovering links, so they are the cheapest way to force that overlap.
+ */
+
+const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" };
+const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" };
+// What the router sends on click: a dynamic RSC refetch, not a prefetch.
+const NAVIGATION = { rsc: "1", "next-url": "/" };
+
+// The tracked-import routes await a dynamic import inside the render, which routes through
+// `trackPendingChunkLoad` and the module loading `CacheSignal` — the state this suite guards.
+const PATHS = [
+ "/ppr",
+ "/ppr/first",
+ "/ppr/second",
+ "/use-cache/ssr",
+ "/use-cache/isr",
+ "/tracked-import/first",
+ "/tracked-import/second",
+];
+
+type Fetched = {
+ path: string;
+ kind: string;
+ status: number;
+ contentType: string;
+ body: Buffer;
+};
+
+async function fetchPath(
+ request: APIRequestContext,
+ path: string,
+ kind: keyof typeof VARIANTS
+): Promise {
+ const response = await request.get(path, { headers: VARIANTS[kind] });
+ return {
+ path,
+ kind,
+ status: response.status(),
+ contentType: response.headers()["content-type"] ?? "",
+ body: await response.body(),
+ };
+}
+
+const VARIANTS = {
+ document: {} as Record,
+ route: ROUTE_PREFETCH,
+ segment: SEGMENT_PREFETCH,
+ navigation: NAVIGATION,
+};
+
+function assertComplete(result: Fetched) {
+ const where = `${result.kind} ${result.path}`;
+
+ // A poisoned isolate answers 200 with an empty or truncated body, so status alone proves nothing.
+ expect(result.status, `${where} should complete`).toEqual(200);
+ expect(result.body.byteLength, `${where} should not be empty`).toBeGreaterThan(0);
+
+ if (result.contentType.includes("text/html")) {
+ // Truncated streams lose the closing tag that Next.js flushes last.
+ expect(result.body.toString("utf8"), `${where} should not be truncated`).toContain("